crewforth 3.1.0-rc.3 → 3.1.0-rc.5
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 +66 -9
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +26 -17
- package/kit/CLAUDE.md +11 -0
- package/kit/agents/crew-backend-expert.md +5 -0
- package/kit/agents/crew-commit-agent.md +8 -3
- package/kit/agents/crew-database-expert.md +5 -0
- package/kit/agents/crew-devops-expert.md +5 -0
- package/kit/agents/crew-frontend-expert.md +5 -0
- package/kit/agents/crew-performance-expert.md +5 -0
- package/kit/agents/crew-planner.md +5 -0
- package/kit/agents/crew-privacy-agent.md +7 -2
- package/kit/agents/crew-review-agent.md +5 -0
- package/kit/agents/crew-security-expert.md +8 -4
- package/kit/agents/crew-session-manager.md +7 -3
- package/kit/agents/crew-test-expert.md +5 -0
- package/kit/eval/doctor.sh +23 -7
- package/kit/eval/smoke-test.sh +377 -27
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-agent-model.sh +392 -0
- package/kit/hooks/guard-bash.sh +61 -5
- package/kit/hooks/guard-schedule.sh +92 -0
- package/kit/hooks/prompt-approval.sh +68 -21
- package/kit/settings.json +22 -0
- package/kit/skills/crew-approve/SKILL.md +26 -0
- package/kit/studio/server/index.js +1 -1
- package/kit/studio/server/lib/graph.js +127 -10
- package/kit/studio/server/lib/usage.js +96 -25
- package/kit/studio/web/app.js +49 -6
- package/kit/studio/web/canvas.js +195 -30
- package/kit/studio/web/chat.js +8 -1
- package/kit/studio/web/convo.js +5 -1
- package/kit/studio/web/graph-plan.js +139 -6
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/list.js +8 -2
- package/kit/studio/web/names.js +65 -0
- package/kit/studio/web/style.css +65 -6
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +10 -3
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
package/kit/studio/web/app.js
CHANGED
|
@@ -24,8 +24,8 @@ import { Timeline } from './timeline.js';
|
|
|
24
24
|
import { List } from './list.js';
|
|
25
25
|
import { defaultView, narrowWarning } from './list-plan.js';
|
|
26
26
|
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
27
|
-
import { roleName, shownType } from './names.js';
|
|
28
|
-
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
27
|
+
import { roleName, shownType, modelFamily } from './names.js';
|
|
28
|
+
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
29
29
|
|
|
30
30
|
const FLEET_POLL_MS = 2000;
|
|
31
31
|
const SESSION_POLL_MS = 5000;
|
|
@@ -419,7 +419,7 @@ el.newSession.addEventListener('click', () => {
|
|
|
419
419
|
|
|
420
420
|
/* The toolbar says what the canvas is doing. It is painted from the canvas's own
|
|
421
421
|
state, so a choice restored from storage shows without being re-made. */
|
|
422
|
-
const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', none: 'None' };
|
|
422
|
+
const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', order: 'Order', none: 'None' };
|
|
423
423
|
const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
|
|
424
424
|
const SHOW = {
|
|
425
425
|
all: { word: 'All', statuses: null },
|
|
@@ -506,7 +506,25 @@ pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGr
|
|
|
506
506
|
canvas.setAside(!railBand.matches);
|
|
507
507
|
railBand.addEventListener('change', () => canvas.setAside(!railBand.matches));
|
|
508
508
|
pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
|
|
509
|
-
|
|
509
|
+
// Show: by state, and by the model the work ran on. The two are separate choices and both hold at once.
|
|
510
|
+
let showModel = null;
|
|
511
|
+
function setShowModel(family) {
|
|
512
|
+
showModel = family;
|
|
513
|
+
canvas.setModelFilter(family);
|
|
514
|
+
timeline.setModelFilter(family);
|
|
515
|
+
list.render?.();
|
|
516
|
+
tbValue.show.textContent = [SHOW[show].word, family ? `${family[0].toUpperCase()}${family.slice(1)}` : null].filter(Boolean).join(' \u00b7 ');
|
|
517
|
+
}
|
|
518
|
+
el.tbShow.addEventListener('click', (e) => {
|
|
519
|
+
e.stopPropagation();
|
|
520
|
+
const items = Object.entries(SHOW).map(([key, w]) => ({ label: w.word, checked: show === key, run: () => { setShow(key); setShowModel(showModel); } }));
|
|
521
|
+
const families = [...new Set((lastNodes ?? []).filter((n) => n.kind === 'agent').map((n) => modelFamily(n.model)).filter(Boolean))].sort();
|
|
522
|
+
if (families.length) {
|
|
523
|
+
items.push({ note: 'Model' }, { label: 'Every model', checked: showModel === null, run: () => setShowModel(null) },
|
|
524
|
+
...families.map((f) => ({ label: `Only ${f[0].toUpperCase()}${f.slice(1)}`, checked: showModel === f, run: () => setShowModel(f) })));
|
|
525
|
+
}
|
|
526
|
+
openMenu(el.tbShow, items);
|
|
527
|
+
});
|
|
510
528
|
// The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
|
|
511
529
|
// Every step is tried from the first, so a toolbar that has room again takes its words back.
|
|
512
530
|
function fitToolbar() {
|
|
@@ -849,6 +867,24 @@ function paintAgentOverview(body, n, detail) {
|
|
|
849
867
|
}
|
|
850
868
|
body.append(grid);
|
|
851
869
|
|
|
870
|
+
// What it ran on and what its call asked for, and what the model gate or a low-confidence report did to it.
|
|
871
|
+
const models = modelLines(n, lastNodes);
|
|
872
|
+
if (models.length) {
|
|
873
|
+
const dl = node('dl', 'imodel');
|
|
874
|
+
for (const m of models) {
|
|
875
|
+
const dd = node('dd', null, m.text);
|
|
876
|
+
if (m.note) dd.title = m.note;
|
|
877
|
+
if (m.goto) {
|
|
878
|
+
const go = node('button', 'ilink', 'show');
|
|
879
|
+
go.type = 'button';
|
|
880
|
+
go.addEventListener('click', () => goTo(m.goto));
|
|
881
|
+
dd.append(' ', go);
|
|
882
|
+
}
|
|
883
|
+
dl.append(node('dt', null, m.label), dd);
|
|
884
|
+
}
|
|
885
|
+
body.append(isection('Model', dl));
|
|
886
|
+
}
|
|
887
|
+
|
|
852
888
|
const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
|
|
853
889
|
const read = n.kind === 'agent' && !gap ? detail : null;
|
|
854
890
|
|
|
@@ -1500,7 +1536,7 @@ function sessionRow(project, sn) {
|
|
|
1500
1536
|
if (subText) row.append(marked(subText, 'sub srow-sub'));
|
|
1501
1537
|
// What it spent, once the server has read it: time · tokens · ~cost. Asked for a few rows at a time.
|
|
1502
1538
|
const spent = usageFor(sn);
|
|
1503
|
-
const line = usageLine(spent);
|
|
1539
|
+
const line = usageLine(spent, { live: sessionStatus(sn.sessionId, fleetData).known && sessionStatus(sn.sessionId, fleetData).key !== 'ended', now: serverNow() });
|
|
1504
1540
|
if (line) {
|
|
1505
1541
|
const u = node('span', 'sub srow-usage', line);
|
|
1506
1542
|
u.title = costNote(spent);
|
|
@@ -2278,6 +2314,7 @@ function paintStageNote() {
|
|
|
2278
2314
|
stageNote.button.hidden = !off;
|
|
2279
2315
|
// The fleet can say a session ended, or came back, with no transcript changing.
|
|
2280
2316
|
if (lastGraph && sessionIsLive() !== lastLive) showGraph();
|
|
2317
|
+
else if (lastLive === true) paintSpend();
|
|
2281
2318
|
paintTerminalWait();
|
|
2282
2319
|
}
|
|
2283
2320
|
|
|
@@ -2285,13 +2322,19 @@ function paintStageNote() {
|
|
|
2285
2322
|
* Draw the graph the stream last sent. What the server read from files is settled against what the machine says
|
|
2286
2323
|
* of the session first, so this is called again when that changes with no file changing.
|
|
2287
2324
|
*/
|
|
2325
|
+
/** The summary box, from the session on screen. A live session's time is counted to now, so this runs on the tick. */
|
|
2326
|
+
function paintSpend() {
|
|
2327
|
+
const usage = lastNodes?.find((n) => n.kind === 'session')?.usage ?? null;
|
|
2328
|
+
canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow() }));
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2288
2331
|
function showGraph() {
|
|
2289
2332
|
if (!lastGraph) return;
|
|
2290
2333
|
lastLive = sessionIsLive();
|
|
2291
2334
|
const g = settle(lastGraph, lastLive);
|
|
2292
2335
|
canvas.render(g);
|
|
2293
|
-
canvas.setSpend(summaryRows(g.nodes.find((n) => n.kind === 'session')?.usage ?? null));
|
|
2294
2336
|
lastNodes = g.nodes;
|
|
2337
|
+
paintSpend();
|
|
2295
2338
|
timeline.setNodes(g.nodes);
|
|
2296
2339
|
list.setNodes(g.nodes);
|
|
2297
2340
|
paintAttention(g.nodes);
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
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, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
|
|
14
|
+
plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
|
|
15
15
|
} from './graph-plan.js';
|
|
16
|
+
import { clock } from './timeline-plan.js';
|
|
16
17
|
import { agentStatus } from './nav.js';
|
|
17
|
-
import { roleName, shownType, hoverOf } from './names.js';
|
|
18
|
+
import { roleName, shownType, hoverOf, orderTag, modelOf, modelMix, modelFamily } from './names.js';
|
|
19
|
+
import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
|
|
18
20
|
|
|
19
21
|
// How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
|
|
20
22
|
// and anything a viewer has to wait through is a cost they pay on every spawn.
|
|
@@ -92,6 +94,7 @@ export class Canvas {
|
|
|
92
94
|
this.palette = { map: {} };
|
|
93
95
|
this.icons = {};
|
|
94
96
|
this.filter = null; // statuses the page asked to isolate
|
|
97
|
+
this.modelFilter = null; // a family of model the page asked to isolate
|
|
95
98
|
this.waiting = new Set(); // agents parked on an approval
|
|
96
99
|
this.sessionState = null; // { word, tone } for the session card's pill
|
|
97
100
|
this.focusId = null; // show only this branch
|
|
@@ -161,7 +164,7 @@ export class Canvas {
|
|
|
161
164
|
close.innerHTML = MARK.close;
|
|
162
165
|
close.addEventListener('click', () => this.showSpend(false));
|
|
163
166
|
head.append(close);
|
|
164
|
-
this.spendRows = mk('
|
|
167
|
+
this.spendRows = mk('div', 'cv-spend-rows');
|
|
165
168
|
box.append(head, this.spendRows);
|
|
166
169
|
this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
|
|
167
170
|
box.hidden = true; // until there is a session's usage to show
|
|
@@ -174,19 +177,61 @@ export class Canvas {
|
|
|
174
177
|
this.spendEl.hidden = !on || !this.spendCount;
|
|
175
178
|
}
|
|
176
179
|
|
|
177
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* The rows to show, from usage.js `summaryRows`: { label, value, note, detail }. An empty list hides the box.
|
|
182
|
+
*
|
|
183
|
+
* A row with a `detail` is a control: a click opens the breakdown under it, and it stays open. Rows are kept and
|
|
184
|
+
* updated, never rebuilt — a live session's time changes every second, and a row replaced under a press would
|
|
185
|
+
* lose the click.
|
|
186
|
+
*/
|
|
178
187
|
setSpend(rows) {
|
|
179
|
-
const
|
|
188
|
+
const list = rows ?? [];
|
|
189
|
+
const sig = JSON.stringify(list);
|
|
180
190
|
if (sig === this.spendSig) return;
|
|
181
191
|
this.spendSig = sig;
|
|
182
|
-
this.spendCount =
|
|
183
|
-
this.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
192
|
+
this.spendCount = list.length;
|
|
193
|
+
this.spendParts ??= new Map();
|
|
194
|
+
this.spendShown ??= new Set();
|
|
195
|
+
const alive = new Set();
|
|
196
|
+
for (const r of list) {
|
|
197
|
+
alive.add(r.label);
|
|
198
|
+
let p = this.spendParts.get(r.label);
|
|
199
|
+
if (!p) {
|
|
200
|
+
p = { row: mk('button', 'cv-spend-row'), label: mk('span', 'cv-spend-label'), value: mk('span', 'cv-spend-value'), detail: mk('dl', 'cv-spend-detail') };
|
|
201
|
+
p.row.type = 'button';
|
|
202
|
+
p.row.append(p.label, p.value);
|
|
203
|
+
p.row.addEventListener('click', () => {
|
|
204
|
+
if (!p.row.classList.contains('cv-spend-more')) return;
|
|
205
|
+
if (this.spendShown.has(r.label)) this.spendShown.delete(r.label); else this.spendShown.add(r.label);
|
|
206
|
+
p.detail.hidden = !this.spendShown.has(r.label);
|
|
207
|
+
p.row.setAttribute('aria-expanded', String(!p.detail.hidden));
|
|
208
|
+
});
|
|
209
|
+
this.spendParts.set(r.label, p);
|
|
210
|
+
}
|
|
211
|
+
const more = Boolean(r.detail?.length);
|
|
212
|
+
p.label.textContent = r.label;
|
|
213
|
+
p.value.textContent = r.value ?? '';
|
|
214
|
+
p.row.title = more ? `${r.note ?? ''}\nClick for the breakdown` : (r.note ?? '');
|
|
215
|
+
p.row.classList.toggle('cv-spend-more', more);
|
|
216
|
+
if (more) p.row.setAttribute('aria-expanded', String(this.spendShown.has(r.label))); else p.row.removeAttribute?.('aria-expanded');
|
|
217
|
+
p.detail.hidden = !more || !this.spendShown.has(r.label);
|
|
218
|
+
const dsig = JSON.stringify(r.detail ?? []);
|
|
219
|
+
if (p.dsig !== dsig) {
|
|
220
|
+
p.dsig = dsig;
|
|
221
|
+
p.detail.replaceChildren(...(r.detail ?? []).flatMap(([k, v]) => [mk('dt', null, k), mk('dd', null, v)]));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const [label, p] of this.spendParts) {
|
|
225
|
+
if (alive.has(label)) continue;
|
|
226
|
+
this.spendParts.delete(label);
|
|
227
|
+
}
|
|
228
|
+
// Which rows there are changes rarely (a session's first agent, its first timed turn); what they say changes
|
|
229
|
+
// every second. Only the first puts elements back into the box, and it puts the same elements back.
|
|
230
|
+
const order = list.map((r) => r.label).join('\u0000');
|
|
231
|
+
if (order !== this.spendOrder) {
|
|
232
|
+
this.spendOrder = order;
|
|
233
|
+
this.spendRows.replaceChildren(...list.flatMap((r) => { const p = this.spendParts.get(r.label); return [p.row, p.detail]; }));
|
|
234
|
+
}
|
|
190
235
|
this.spendEl.hidden = !this.spendOpen || !this.spendCount;
|
|
191
236
|
}
|
|
192
237
|
|
|
@@ -213,6 +258,10 @@ export class Canvas {
|
|
|
213
258
|
const rows = mk('div', 'cv-legend-grid');
|
|
214
259
|
rows.append(line('live', 'working now'), line('waiting', 'needs you'), line('done', 'finished'), line('failed', 'failed'));
|
|
215
260
|
box.append(rows);
|
|
261
|
+
// The one wire that is not a call: in the Order view, from an agent to the wave called after it reported.
|
|
262
|
+
const inferred = line('inferred', 'after it reported (inferred, not a call)');
|
|
263
|
+
inferred.title = INFERRED_NOTE;
|
|
264
|
+
box.append(inferred);
|
|
216
265
|
const marks = mk('div', 'cv-legend-grid');
|
|
217
266
|
this.legendBuiltin = mk('span', 'cv-tile cv-tile-sm');
|
|
218
267
|
const crew = mk('span', 'cv-tile cv-tile-sm');
|
|
@@ -396,7 +445,17 @@ export class Canvas {
|
|
|
396
445
|
this.#redraw();
|
|
397
446
|
}
|
|
398
447
|
|
|
448
|
+
/** Show only the agents that ran on one family of model (`'opus'`), stepping the rest back. `null` shows all. */
|
|
449
|
+
setModelFilter(family) {
|
|
450
|
+
if ((family ?? null) === this.modelFilter) return;
|
|
451
|
+
this.modelFilter = family ?? null;
|
|
452
|
+
for (const el of this.els.values()) el.sig = null;
|
|
453
|
+
for (const el of this.cellEls.values()) el.sig = null;
|
|
454
|
+
if (this.lastPlan) this.#redraw();
|
|
455
|
+
}
|
|
456
|
+
|
|
399
457
|
dimmed(n) {
|
|
458
|
+
if (n?.kind === 'agent' && this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
|
|
400
459
|
if (!this.filter || n?.kind !== 'agent') return false;
|
|
401
460
|
if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
|
|
402
461
|
return !this.filter.has(n.status);
|
|
@@ -647,7 +706,7 @@ export class Canvas {
|
|
|
647
706
|
el.classList.toggle('cv-pinned', Boolean(it.pinned));
|
|
648
707
|
if (it.kind === 'session') this.#fillSession(el, it);
|
|
649
708
|
else if (it.kind === 'group') this.#fillGroup(el, it, liveCells);
|
|
650
|
-
else this.#fillAgent(el, it.node, it.compact);
|
|
709
|
+
else this.#fillAgent(el, it.node, it.compact, it.after ?? null);
|
|
651
710
|
}
|
|
652
711
|
|
|
653
712
|
/** The skeleton of a card: the parts it will fill. */
|
|
@@ -662,7 +721,11 @@ export class Canvas {
|
|
|
662
721
|
row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
|
|
663
722
|
p.name = mk('div', 'cv-name cv-indent');
|
|
664
723
|
p.sub = mk('div', 'cv-sub cv-indent');
|
|
665
|
-
|
|
724
|
+
// What it has running in the background takes a line of its own, when there is any: on the line above it
|
|
725
|
+
// ran out of the card.
|
|
726
|
+
p.bg = mk('div', 'cv-sub cv-indent cv-bg');
|
|
727
|
+
p.bg.hidden = true;
|
|
728
|
+
el.append(row, p.name, p.sub, p.bg);
|
|
666
729
|
} else if (it.kind === 'group') {
|
|
667
730
|
el.classList.add('cv-group');
|
|
668
731
|
p.tile = mk('span', 'cv-tile');
|
|
@@ -680,24 +743,39 @@ export class Canvas {
|
|
|
680
743
|
} else {
|
|
681
744
|
const row = mk('div', 'cv-row cv-indent');
|
|
682
745
|
row.append(p.bar, p.count);
|
|
683
|
-
|
|
746
|
+
// What its members add up to, and what they ran on. At compact density the card has no room for them.
|
|
747
|
+
p.totals = mk('div', 'cv-sub cv-indent cv-totals');
|
|
748
|
+
p.mix = mk('div', 'cv-sub cv-indent cv-mix');
|
|
749
|
+
el.append(p.head, row, p.totals, p.mix);
|
|
684
750
|
}
|
|
685
751
|
p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
|
|
686
752
|
} else if (it.compact) {
|
|
687
753
|
el.classList.add('cv-chip');
|
|
688
754
|
p.dot = mk('span', 'dot');
|
|
755
|
+
p.order = mk('span', 'cv-order');
|
|
689
756
|
p.name = mk('span', 'cv-name');
|
|
690
757
|
p.task = mk('span', 'cv-task');
|
|
758
|
+
p.model = mk('span', 'cv-model');
|
|
691
759
|
p.word = mk('span', 'cv-word');
|
|
692
|
-
el.append(p.dot, p.name, p.task, p.word);
|
|
760
|
+
el.append(p.dot, p.order, p.name, p.task, p.model, p.word);
|
|
693
761
|
} else {
|
|
694
762
|
p.tile = mk('span', 'cv-tile');
|
|
695
763
|
p.name = mk('span', 'cv-name');
|
|
764
|
+
p.order = mk('span', 'cv-order');
|
|
696
765
|
p.pill = this.#pill();
|
|
697
766
|
const row = mk('div', 'cv-row');
|
|
698
767
|
row.append(p.tile, p.name, mk('span', 'cv-fill'), p.pill.el);
|
|
699
|
-
|
|
700
|
-
|
|
768
|
+
// The second line: the task, and at its end where the card stands in the order of the calls. The name
|
|
769
|
+
// keeps the first line to itself.
|
|
770
|
+
p.task = mk('span', 'cv-task');
|
|
771
|
+
const under = mk('div', 'cv-under cv-indent');
|
|
772
|
+
under.append(p.task, p.order);
|
|
773
|
+
// The third line: what it ran on, how long it took and what it spent.
|
|
774
|
+
p.model = mk('span', 'cv-model');
|
|
775
|
+
p.facts = mk('span', 'cv-facts');
|
|
776
|
+
const facts = mk('div', 'cv-under cv-indent cv-third');
|
|
777
|
+
facts.append(p.model, p.facts);
|
|
778
|
+
el.append(row, under, facts);
|
|
701
779
|
}
|
|
702
780
|
el.parts = p;
|
|
703
781
|
}
|
|
@@ -758,23 +836,31 @@ export class Canvas {
|
|
|
758
836
|
p.pill.dot.dataset.tone = st?.tone ?? 'none';
|
|
759
837
|
p.pill.word.textContent = st?.word ?? '';
|
|
760
838
|
p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
|
|
839
|
+
// Cut to the card's width; hovering says it whole.
|
|
840
|
+
p.name.title = p.name.textContent;
|
|
761
841
|
const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
|
|
762
842
|
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`);
|
|
765
843
|
p.sub.textContent = bits.join(' · ');
|
|
766
|
-
p.sub.title =
|
|
844
|
+
p.sub.title = p.sub.textContent;
|
|
845
|
+
// What the session has running that is not an agent: commands sent to the background.
|
|
846
|
+
p.bg.hidden = !bg;
|
|
847
|
+
p.bg.textContent = bg ? `${bg} in background` : '';
|
|
848
|
+
p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
|
|
767
849
|
el.title = 'Click to inspect this session';
|
|
768
850
|
el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
|
|
769
851
|
}
|
|
770
852
|
|
|
771
|
-
#fillAgent(el, n, compact) {
|
|
853
|
+
#fillAgent(el, n, compact, after = null) {
|
|
772
854
|
const st = this.#statusOf(n);
|
|
773
855
|
const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
|
|
774
856
|
const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : shownType(n);
|
|
775
857
|
const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
|
|
776
858
|
const dim = this.dimmed(n);
|
|
777
|
-
|
|
859
|
+
// Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
|
|
860
|
+
const tag = orderTag(n, compact ? null : clock);
|
|
861
|
+
const mdl = modelOf(n);
|
|
862
|
+
const facts = compact ? '' : factsOf(n);
|
|
863
|
+
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after, mdl.ran, mdl.differs, facts]);
|
|
778
864
|
if (el.sig === sig) return;
|
|
779
865
|
el.sig = sig;
|
|
780
866
|
const p = el.parts;
|
|
@@ -789,6 +875,18 @@ export class Canvas {
|
|
|
789
875
|
// Both are cut to the card's width; hovering says them whole, and the type as it is declared.
|
|
790
876
|
p.name.title = n.kind === 'workflow' ? '' : (n.agentType ?? '');
|
|
791
877
|
p.task.title = task;
|
|
878
|
+
p.order.textContent = tag;
|
|
879
|
+
p.order.hidden = !tag;
|
|
880
|
+
// The model it ran on, as a badge; marked when the call asked for another. A chip has room for the family.
|
|
881
|
+
const family = modelFamily(n.model);
|
|
882
|
+
p.model.textContent = `${compact ? (family ? family[0].toUpperCase() + family.slice(1) : mdl.ran) : mdl.ran}${mdl.differs ? ' \u2260' : ''}`;
|
|
883
|
+
p.model.hidden = !mdl.ran;
|
|
884
|
+
p.model.title = mdl.title;
|
|
885
|
+
p.model.dataset.differs = String(mdl.differs);
|
|
886
|
+
if (!compact) { p.facts.textContent = facts; p.facts.title = factsTitle(n); }
|
|
887
|
+
p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
|
|
888
|
+
// In the Order view: what had reported before this wave was called. A reading of the order of events.
|
|
889
|
+
el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
|
|
792
890
|
if (compact) {
|
|
793
891
|
p.dot.dataset.tone = st.tone ?? 'none';
|
|
794
892
|
p.word.textContent = st.word;
|
|
@@ -806,7 +904,7 @@ export class Canvas {
|
|
|
806
904
|
const p = el.parts;
|
|
807
905
|
const counts = it.counts;
|
|
808
906
|
const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
|
|
809
|
-
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status]);
|
|
907
|
+
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null, it.compact, it.sum, modelMix(it.members)]);
|
|
810
908
|
if (el.sig !== sig) {
|
|
811
909
|
el.sig = sig;
|
|
812
910
|
el.dataset.state = stateOf(it.status);
|
|
@@ -820,7 +918,9 @@ export class Canvas {
|
|
|
820
918
|
if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
|
|
821
919
|
else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
|
|
822
920
|
// Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
|
|
823
|
-
|
|
921
|
+
const size = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
|
|
922
|
+
// The group stands where its first member does in the order of the calls.
|
|
923
|
+
p.sub.textContent = [orderTag(it.first), size].filter(Boolean).join(' · ');
|
|
824
924
|
p.sub.classList.toggle('cv-times', it.type === 'type');
|
|
825
925
|
p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
|
|
826
926
|
// Read aloud, the badge is part of the name.
|
|
@@ -838,6 +938,7 @@ export class Canvas {
|
|
|
838
938
|
}));
|
|
839
939
|
p.bar.title = says;
|
|
840
940
|
if (!it.boxed) p.count.textContent = says;
|
|
941
|
+
if (!it.boxed) this.#fillTotals(p, it);
|
|
841
942
|
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
842
943
|
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
843
944
|
}
|
|
@@ -856,8 +957,9 @@ export class Canvas {
|
|
|
856
957
|
c.tabIndex = 0;
|
|
857
958
|
const q = {};
|
|
858
959
|
if (!it.grid) { q.tile = mk('span', 'cv-tile'); c.append(q.tile); } else { q.dot = mk('span', 'dot'); c.append(q.dot); }
|
|
960
|
+
q.order = mk('span', 'cv-order');
|
|
859
961
|
q.name = mk('span', 'cv-name');
|
|
860
|
-
c.append(q.name);
|
|
962
|
+
c.append(q.order, q.name);
|
|
861
963
|
if (!it.grid) { c.append(mk('span', 'cv-fill')); q.dot = mk('span', 'dot'); c.append(q.dot); }
|
|
862
964
|
q.word = mk('span', 'cv-word');
|
|
863
965
|
c.append(q.word);
|
|
@@ -879,7 +981,8 @@ export class Canvas {
|
|
|
879
981
|
// Inside a group of one type the type is the group's name, so a member is told apart by its task.
|
|
880
982
|
const text = it.type === 'type' ? (n.description || shownType(n) || n.id) : shownType(n);
|
|
881
983
|
const dim = this.dimmed(n);
|
|
882
|
-
const
|
|
984
|
+
const ctag = orderTag(n, it.grid ? null : clock);
|
|
985
|
+
const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id, ctag]);
|
|
883
986
|
if (c.sig === csig) continue;
|
|
884
987
|
c.sig = csig;
|
|
885
988
|
c.dataset.state = st.state;
|
|
@@ -887,13 +990,56 @@ export class Canvas {
|
|
|
887
990
|
c.classList.toggle('cv-dim', dim);
|
|
888
991
|
c.classList.toggle('cv-selected', this.selected === n.id);
|
|
889
992
|
c.parts.name.textContent = text;
|
|
890
|
-
c.
|
|
993
|
+
c.parts.order.textContent = ctag;
|
|
994
|
+
c.parts.order.hidden = !ctag;
|
|
995
|
+
c.title = [hoverOf(n), [modelOf(n).ran, factsOf(n)].filter(Boolean).join(' \u00b7 ')].filter(Boolean).join('\n');
|
|
891
996
|
c.parts.dot.dataset.tone = st.tone ?? 'none';
|
|
892
997
|
c.parts.word.textContent = st.word;
|
|
893
998
|
c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
|
|
894
999
|
}
|
|
895
1000
|
}
|
|
896
1001
|
|
|
1002
|
+
/** A group as one card: the time, the tokens and the estimate its members add up to, and the models they ran on. */
|
|
1003
|
+
#fillTotals(p, it) {
|
|
1004
|
+
const s = it.sum ?? {};
|
|
1005
|
+
p.totals.hidden = Boolean(it.compact);
|
|
1006
|
+
p.mix.hidden = Boolean(it.compact);
|
|
1007
|
+
if (it.compact) return;
|
|
1008
|
+
p.totals.textContent = [
|
|
1009
|
+
s.timed ? fmtDuration(s.durationMs) : null,
|
|
1010
|
+
s.counted ? `${fmtCount(s.fresh)} new` : null,
|
|
1011
|
+
s.counted ? `${fmtCount(s.cacheRead)} cache` : null,
|
|
1012
|
+
s.counted ? fmtCost(s.cost) : null,
|
|
1013
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
1014
|
+
p.totals.title = `${it.members.length} calls. Time, new tokens, tokens read from the cache, and cost: ${COST_NOTE}`;
|
|
1015
|
+
p.mix.textContent = modelMix(it.members);
|
|
1016
|
+
p.mix.title = 'The models its agents ran on';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
|
|
1020
|
+
#edgeWords(path, e, c) {
|
|
1021
|
+
const title = e.title ?? '';
|
|
1022
|
+
if (path.said !== title) {
|
|
1023
|
+
path.said = title;
|
|
1024
|
+
path.querySelector?.('title')?.remove();
|
|
1025
|
+
if (title) {
|
|
1026
|
+
const t = document.createElementNS(SVG, 'title');
|
|
1027
|
+
t.textContent = title;
|
|
1028
|
+
path.append(t);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (!e.label) { path.label?.remove(); path.label = null; return; }
|
|
1032
|
+
if (!path.label) {
|
|
1033
|
+
path.label = document.createElementNS(SVG, 'text');
|
|
1034
|
+
path.label.setAttribute('class', 'cv-edge-label');
|
|
1035
|
+
this.edgeG.append(path.label);
|
|
1036
|
+
}
|
|
1037
|
+
path.label.textContent = e.label;
|
|
1038
|
+
// Just off the reporter's edge, above the wire, where it does not sit on a card.
|
|
1039
|
+
path.label.setAttribute('x', String(e.from.x + 8));
|
|
1040
|
+
path.label.setAttribute('y', String(e.from.y - 6));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
897
1043
|
// Wires are kept and updated, never rebuilt.
|
|
898
1044
|
//
|
|
899
1045
|
// Replacing the whole layer each pass was the reason motion could not live here: a CSS animation restarts when
|
|
@@ -932,9 +1078,13 @@ export class Canvas {
|
|
|
932
1078
|
|
|
933
1079
|
const target = this.nodes.get(e.target);
|
|
934
1080
|
const waiting = target && this.waiting.has(target.id);
|
|
935
|
-
|
|
1081
|
+
// A wire that is a reading of the order of events says nothing about what its target is doing now.
|
|
1082
|
+
const inferred = e.kind === 'inferred';
|
|
1083
|
+
const state = inferred ? 'inferred' : (waiting ? 'waiting' : e.state);
|
|
936
1084
|
path.dataset.status = e.status;
|
|
937
1085
|
path.dataset.state = state;
|
|
1086
|
+
path.dataset.kind = inferred ? 'inferred' : 'call';
|
|
1087
|
+
this.#edgeWords(path, e, c);
|
|
938
1088
|
path.classList.toggle('cv-dim', Boolean(target) && this.dimmed(target));
|
|
939
1089
|
path.classList.toggle('cv-path', onPath.has(e.target));
|
|
940
1090
|
|
|
@@ -950,6 +1100,7 @@ export class Canvas {
|
|
|
950
1100
|
|
|
951
1101
|
for (const [key, path] of this.edgeEls) {
|
|
952
1102
|
if (alive.has(key)) continue;
|
|
1103
|
+
path.label?.remove();
|
|
953
1104
|
path.remove();
|
|
954
1105
|
this.edgeEls.delete(key);
|
|
955
1106
|
}
|
|
@@ -1277,6 +1428,20 @@ function readPref(key, allowed, fallback) {
|
|
|
1277
1428
|
return allowed.includes(v) ? v : fallback;
|
|
1278
1429
|
}
|
|
1279
1430
|
|
|
1431
|
+
/** An agent's third line: how long it took and what it spent. */
|
|
1432
|
+
function factsOf(n) {
|
|
1433
|
+
return [
|
|
1434
|
+
n.durationMs != null ? fmtDuration(n.durationMs) : null,
|
|
1435
|
+
n.usage ? `${fmtCount(n.usage.tokens.fresh)} new` : null,
|
|
1436
|
+
n.usage ? fmtCost(n.usage.cost) : null,
|
|
1437
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
1438
|
+
}
|
|
1439
|
+
function factsTitle(n) {
|
|
1440
|
+
if (!n.usage) return '';
|
|
1441
|
+
const t = n.usage.tokens;
|
|
1442
|
+
return `New tokens ${fmtCount(t.fresh)} (input ${fmtCount(t.input)}, output ${fmtCount(t.output)}, cache write ${fmtCount(t.cacheWrite)}); read from cache ${fmtCount(t.cacheRead)}\n${COST_NOTE}`;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1280
1445
|
function fmtTokens(t) {
|
|
1281
1446
|
if (t == null) return '';
|
|
1282
1447
|
if (t < 1000) return `${t}`;
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -370,7 +370,7 @@ class Pane {
|
|
|
370
370
|
|
|
371
371
|
paintCard({ block, node }) {
|
|
372
372
|
const card = delegationCard(block, this.hooks.agentsOf?.(this.id));
|
|
373
|
-
const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}`;
|
|
373
|
+
const sig = `${card.type}|${card.task}|${card.agentId}|${card.status}|${card.model.ran}|${card.model.asked}`;
|
|
374
374
|
if (node.sig === sig) return;
|
|
375
375
|
node.sig = sig;
|
|
376
376
|
const text = el('span', 'deleg-text');
|
|
@@ -380,6 +380,13 @@ class Pane {
|
|
|
380
380
|
const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
|
|
381
381
|
if (tile) parts.push(tile);
|
|
382
382
|
parts.push(text);
|
|
383
|
+
// The model: what it ran on, or what the call asks for while the agent has not been seen yet.
|
|
384
|
+
const mword = card.model.ran || card.model.asked;
|
|
385
|
+
if (mword) {
|
|
386
|
+
const badge = el('span', 'deleg-model', `${mword}${card.model.differs ? ' \u2260' : ''}`);
|
|
387
|
+
badge.title = card.model.title;
|
|
388
|
+
parts.push(badge);
|
|
389
|
+
}
|
|
383
390
|
// The agent's state is the graph's. Before the graph has seen the agent there is none to show.
|
|
384
391
|
const st = card.agentId ? this.hooks.statusOf?.(this.id, card.agentId) : null;
|
|
385
392
|
if (st?.word) {
|
package/kit/studio/web/convo.js
CHANGED
|
@@ -3,7 +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
|
+
import { roleName, modelOf } from './names.js';
|
|
7
7
|
|
|
8
8
|
// How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
|
|
9
9
|
const RESULT_TAIL = 4096;
|
|
@@ -20,6 +20,8 @@ export function toolBlock(c) {
|
|
|
20
20
|
label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
|
|
21
21
|
id: typeof c?.id === 'string' ? c.id : null,
|
|
22
22
|
subagentType: typeof i.subagent_type === 'string' ? i.subagent_type : null,
|
|
23
|
+
// The model a delegation asks for; null when the call names none.
|
|
24
|
+
model: typeof i.model === 'string' ? i.model : null,
|
|
23
25
|
result: null,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
@@ -82,6 +84,8 @@ export function delegationCard(block, nodes) {
|
|
|
82
84
|
task: block.label ?? node?.description ?? '',
|
|
83
85
|
agentId: node?.id ?? null,
|
|
84
86
|
status: node?.status ?? null,
|
|
87
|
+
// What it ran on once the graph knows the agent; before that, what the call asked for.
|
|
88
|
+
model: modelOf({ model: node?.model ?? null, modelAsked: block.model ?? node?.modelAsked ?? null }),
|
|
85
89
|
};
|
|
86
90
|
}
|
|
87
91
|
|