crewforth 3.1.0-rc.2 → 3.1.0-rc.4
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 +70 -4
- package/README.md +1 -1
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +274 -18
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +93 -6
- package/kit/hooks/guard-schedule.sh +92 -0
- package/kit/hooks/prompt-approval.sh +68 -21
- package/kit/settings.json +11 -0
- package/kit/skills/crew-approve/SKILL.md +26 -0
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +137 -15
- 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 +159 -38
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +158 -30
- 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 +173 -13
- 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 +44 -0
- package/kit/studio/web/style.css +64 -10
- package/kit/studio/web/timeline-plan.js +13 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
|
@@ -15,11 +15,14 @@
|
|
|
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 = {
|
|
21
23
|
padX: 24, padY: 16, // the canvas's own margin
|
|
22
24
|
colGap: 56, // between one depth and the next
|
|
25
|
+
waveGap: 148, // between one wave and the next: room for the words on an inferred wire
|
|
23
26
|
rowGap: 12, // between siblings
|
|
24
27
|
session: { w: 200, h: 84 },
|
|
25
28
|
card: { w: 248, h: 64 }, // an agent, and a folded group
|
|
@@ -46,7 +49,7 @@ export const AUTO = {
|
|
|
46
49
|
stackAt: 3,
|
|
47
50
|
};
|
|
48
51
|
|
|
49
|
-
export const GROUPINGS = ['run', 'type', 'parent', 'none'];
|
|
52
|
+
export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
|
|
50
53
|
export const DENSITIES = ['auto', 'comfortable', 'compact'];
|
|
51
54
|
|
|
52
55
|
// The handful of things a status means to a reader. `killed` and `stopped` come off the transcript verbatim and
|
|
@@ -68,7 +71,6 @@ const SESSION = 'session';
|
|
|
68
71
|
|
|
69
72
|
/* --------------------------------------------------------------- groups --- */
|
|
70
73
|
|
|
71
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
72
74
|
const byArrival = (seq) => (a, b) => (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0);
|
|
73
75
|
|
|
74
76
|
/** Counts by status, in the order a bar draws them. */
|
|
@@ -132,8 +134,11 @@ export function sequence(nodes, seq = new Map()) {
|
|
|
132
134
|
*
|
|
133
135
|
* At `auto` density, once the picture is too tall (`stack`), siblings of one type fold into a group from
|
|
134
136
|
* AUTO.stackAt whatever the grouping is, except under `none`, which means none.
|
|
137
|
+
*
|
|
138
|
+
* `aside(id)` says a type group is open beside itself: its members are then items of their own, one column to the
|
|
139
|
+
* right of the group's card, and what they spawned is a column further on.
|
|
135
140
|
*/
|
|
136
|
-
function itemsOf(nodes, { group, density, seq, stack }) {
|
|
141
|
+
function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
137
142
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
138
143
|
const kids = new Map();
|
|
139
144
|
for (const n of nodes) {
|
|
@@ -158,7 +163,7 @@ function itemsOf(nodes, { group, density, seq, stack }) {
|
|
|
158
163
|
// An agent's children as one group.
|
|
159
164
|
if (group === 'parent' && list.length >= 2 && byId.get(parentId)?.kind === 'agent') {
|
|
160
165
|
const g = groupItem(`kids:${parentId}`, 'parent', list, anchor, via, depth, seq, {
|
|
161
|
-
label: `${
|
|
166
|
+
label: `${shownType(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
|
|
162
167
|
});
|
|
163
168
|
items.push(g);
|
|
164
169
|
for (const m of list) place(m.id, g, m.id, depth + 1);
|
|
@@ -176,10 +181,18 @@ function itemsOf(nodes, { group, density, seq, stack }) {
|
|
|
176
181
|
for (const [t, members] of types) {
|
|
177
182
|
if (members.length < stackAt) continue;
|
|
178
183
|
const g = groupItem(`type:${parentId}:${t}`, 'type', members, anchor, via, depth, seq, {
|
|
179
|
-
|
|
184
|
+
// The role is the name and the number is a badge beside it: `count` is drawn apart from `label`.
|
|
185
|
+
label: roleName(t), count: members.length, real: t, agentType: members[0].agentType ?? null,
|
|
180
186
|
});
|
|
181
187
|
items.push(g);
|
|
182
|
-
|
|
188
|
+
g.aside = Boolean(aside?.(g.id));
|
|
189
|
+
for (const m of members) {
|
|
190
|
+
stacked.add(m.id);
|
|
191
|
+
if (!g.aside) { place(m.id, g, m.id, depth + 1); continue; }
|
|
192
|
+
const item = { id: m.id, kind: 'agent', node: m, parent: g, via: null, depth: depth + 1, seq: seq.get(m.id) ?? 0, member: true };
|
|
193
|
+
items.push(item);
|
|
194
|
+
place(m.id, item, null, depth + 2);
|
|
195
|
+
}
|
|
183
196
|
}
|
|
184
197
|
|
|
185
198
|
for (const n of list) {
|
|
@@ -215,6 +228,8 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
215
228
|
via,
|
|
216
229
|
depth,
|
|
217
230
|
seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
|
|
231
|
+
// The group stands where its first member does in the order the session called its agents.
|
|
232
|
+
first: members.filter((m) => m.order != null).sort((a, b) => a.order - b.order)[0] ?? null,
|
|
218
233
|
...extra,
|
|
219
234
|
};
|
|
220
235
|
}
|
|
@@ -229,17 +244,21 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
229
244
|
* @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
|
|
230
245
|
* @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
|
|
231
246
|
* @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
|
|
247
|
+
* @param opts.aside true when an open type group puts its agents in the next column instead of growing
|
|
248
|
+
* downward; a narrow window leaves it off
|
|
232
249
|
* @returns { items, edges, width, height, drawn, density, sessionY }
|
|
233
250
|
*/
|
|
234
251
|
export function plan(nodes, opts = {}) {
|
|
235
252
|
const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
|
|
236
253
|
const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
|
|
254
|
+
if (group === 'order') return planOrder(nodes, asked, opts);
|
|
237
255
|
const seq = opts.seq ?? sequence(nodes);
|
|
238
256
|
const open = opts.open ?? new Map();
|
|
239
257
|
const pinned = opts.pinned ?? new Map();
|
|
240
258
|
|
|
241
259
|
// A type group starts folded: it is the same thing several times. A run or an agent's children start open.
|
|
242
260
|
const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : g.type !== 'type');
|
|
261
|
+
const aside = opts.aside ? (id) => open.get(id) === true : null;
|
|
243
262
|
|
|
244
263
|
// What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
|
|
245
264
|
const drawFrom = (items) => {
|
|
@@ -254,7 +273,8 @@ export function plan(nodes, opts = {}) {
|
|
|
254
273
|
let count = 0;
|
|
255
274
|
for (const it of shown) {
|
|
256
275
|
if (it.kind === 'agent' || it.kind === 'run-card') count += 1;
|
|
257
|
-
|
|
276
|
+
// A group open beside itself has its agents among the items already.
|
|
277
|
+
else if (it.kind === 'group' && !it.aside) count += isOpen(it) ? it.members.length : 1;
|
|
258
278
|
}
|
|
259
279
|
return { shown, count };
|
|
260
280
|
};
|
|
@@ -271,7 +291,9 @@ export function plan(nodes, opts = {}) {
|
|
|
271
291
|
continue;
|
|
272
292
|
}
|
|
273
293
|
it.open = isOpen(it);
|
|
274
|
-
|
|
294
|
+
// `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
|
|
295
|
+
it.boxed = it.open && !it.aside;
|
|
296
|
+
if (!it.boxed) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
|
|
275
297
|
const n = it.members.length;
|
|
276
298
|
const grid = compact || n > G.rowsMax;
|
|
277
299
|
const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
|
|
@@ -349,13 +371,13 @@ export function plan(nodes, opts = {}) {
|
|
|
349
371
|
|
|
350
372
|
// Auto, in the order it reaches for things: nothing while the picture is short enough to read; siblings of one
|
|
351
373
|
// 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 }));
|
|
374
|
+
let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false, aside }));
|
|
353
375
|
let compact = asked === 'compact';
|
|
354
376
|
let stacked = false;
|
|
355
377
|
let box = lay(pass.shown, compact);
|
|
356
378
|
if (asked === 'auto' && box.height > AUTO.tallest) {
|
|
357
379
|
if (group !== 'none' && group !== 'type') {
|
|
358
|
-
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true }));
|
|
380
|
+
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
|
|
359
381
|
if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
|
|
360
382
|
}
|
|
361
383
|
if (box.height > AUTO.tallest) { compact = true; box = lay(pass.shown, true); }
|
|
@@ -400,7 +422,108 @@ export function plan(nodes, opts = {}) {
|
|
|
400
422
|
|
|
401
423
|
return {
|
|
402
424
|
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,
|
|
425
|
+
drawn, density, group, stacked, aside: Boolean(aside), sessionY: sessionY ?? null,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ---------------------------------------------------------------- order --- */
|
|
430
|
+
|
|
431
|
+
// What the wire from an agent to a later wave is called, and what it is: a reading of the order of events.
|
|
432
|
+
export const INFERRED_NOTE = 'Inferred from the order of events: this wave was called after that agent reported. It is not a call from one agent to another; the session calls every agent.';
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The same agents by when the session called them.
|
|
436
|
+
*
|
|
437
|
+
* Every agent is called by the session, so the tree is one level deep and says nothing of sequence. Here a
|
|
438
|
+
* column is a WAVE: the agents called in one assistant message, which is what "started together" means. Waves
|
|
439
|
+
* run left to right in the order the messages were written, and a wave's agents stack in the order of the calls.
|
|
440
|
+
* Agents the session's transcript did not call (a workflow's) have no wave and take a last column of their own.
|
|
441
|
+
*
|
|
442
|
+
* Two kinds of wire. The session is wired to the first wave: it called it. A later wave is wired, dotted, from
|
|
443
|
+
* each agent that reported after the wave before it was called and before this one was — an inference from the
|
|
444
|
+
* order of events, drawn as one and labelled as one. A wave nothing reported before has no wire.
|
|
445
|
+
*/
|
|
446
|
+
function planOrder(nodes, asked, opts) {
|
|
447
|
+
const pinned = opts.pinned ?? new Map();
|
|
448
|
+
const session = nodes.find((n) => n.kind === 'session') ?? null;
|
|
449
|
+
const agents = nodes.filter((n) => n.kind === 'agent');
|
|
450
|
+
const called = agents.filter((n) => n.wave != null);
|
|
451
|
+
const waveIds = [...new Set(called.map((n) => n.wave))].sort((a, b) => a - b);
|
|
452
|
+
const byOrder = (a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.id).localeCompare(String(b.id));
|
|
453
|
+
const columns = waveIds.map((w) => ({ wave: w, members: called.filter((n) => n.wave === w).sort(byOrder) }));
|
|
454
|
+
for (const c of columns) c.calledAt = Math.min(...c.members.map((m) => m.calledAt ?? Infinity));
|
|
455
|
+
const rest = agents.filter((n) => n.wave == null)
|
|
456
|
+
.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
|
|
457
|
+
if (rest.length) columns.push({ wave: null, members: rest, calledAt: Infinity });
|
|
458
|
+
|
|
459
|
+
const lay = (compact) => {
|
|
460
|
+
const sz = compact ? SIZE.chip : SIZE.card;
|
|
461
|
+
const items = [];
|
|
462
|
+
const x0 = SIZE.padX + (session ? SIZE.session.w + SIZE.colGap : 0);
|
|
463
|
+
let height = 0;
|
|
464
|
+
columns.forEach((col, i) => {
|
|
465
|
+
col.members.forEach((n, j) => {
|
|
466
|
+
items.push({
|
|
467
|
+
id: n.id, kind: 'agent', node: n, parent: null, via: null, depth: i + 1, seq: n.order ?? j, compact,
|
|
468
|
+
wave: col.wave, w: sz.w, h: sz.h, x: x0 + i * (sz.w + SIZE.waveGap), y: SIZE.padY + j * (sz.h + SIZE.rowGap),
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
height = Math.max(height, SIZE.padY * 2 + col.members.length * (sz.h + SIZE.rowGap) - SIZE.rowGap);
|
|
472
|
+
});
|
|
473
|
+
const width = x0 + Math.max(0, columns.length * (sz.w + SIZE.waveGap) - SIZE.waveGap) + SIZE.padX;
|
|
474
|
+
return { items, width, height };
|
|
475
|
+
};
|
|
476
|
+
let compact = asked === 'compact';
|
|
477
|
+
let box = lay(compact);
|
|
478
|
+
if (asked === 'auto' && box.height > AUTO.tallest) { compact = true; box = lay(true); }
|
|
479
|
+
const { items } = box;
|
|
480
|
+
let { height } = box;
|
|
481
|
+
|
|
482
|
+
let sessionY = opts.sessionY ?? null;
|
|
483
|
+
if (session) {
|
|
484
|
+
const first = items.filter((it) => it.depth === 1);
|
|
485
|
+
if (sessionY == null) {
|
|
486
|
+
const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY + SIZE.session.h;
|
|
487
|
+
sessionY = Math.max(SIZE.padY, Math.round((SIZE.padY + bottom) / 2 - SIZE.session.h / 2));
|
|
488
|
+
}
|
|
489
|
+
items.unshift({ id: session.id, kind: 'session', node: session, parent: null, depth: 0, w: SIZE.session.w, h: SIZE.session.h, x: SIZE.padX, y: sessionY });
|
|
490
|
+
height = Math.max(height, sessionY + SIZE.session.h + SIZE.padY);
|
|
491
|
+
}
|
|
492
|
+
for (const it of items) {
|
|
493
|
+
const p = pinned.get(it.id);
|
|
494
|
+
if (p) { it.x = p.x; it.y = p.y; it.pinned = true; }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const at = new Map(items.map((it) => [it.id, it]));
|
|
498
|
+
const edges = [];
|
|
499
|
+
const wire = (from, to, extra) => edges.push({
|
|
500
|
+
key: `${from.id}\u0000${to.id}`, source: from.id, target: to.id,
|
|
501
|
+
from: { x: from.x + from.w, y: from.y + from.h / 2 }, to: { x: to.x, y: to.y + to.h / 2 },
|
|
502
|
+
status: to.node.status ?? 'unknown', state: stateOf(to.node.status), ...extra,
|
|
503
|
+
});
|
|
504
|
+
const waves = columns.filter((c) => c.wave != null);
|
|
505
|
+
if (session && waves.length) for (const m of waves[0].members) wire(at.get(session.id), at.get(m.id), {});
|
|
506
|
+
// An agent's report belongs to the first wave called after it: the one that could have been waiting for it.
|
|
507
|
+
for (let i = 1; i < waves.length; i += 1) {
|
|
508
|
+
const since = waves[i - 1].calledAt;
|
|
509
|
+
const reporters = called.filter((n) => n.reportedAt != null && n.reportedAt > since && n.reportedAt <= waves[i].calledAt
|
|
510
|
+
&& n.wave < waves[i].wave).sort(byOrder);
|
|
511
|
+
waves[i].after = reporters;
|
|
512
|
+
for (const r of reporters) {
|
|
513
|
+
waves[i].members.forEach((m, k) => wire(at.get(r.id), at.get(m.id), {
|
|
514
|
+
kind: 'inferred', state: 'inferred',
|
|
515
|
+
// Said once for a reporter, on the wire to the wave's first agent; the others carry it on hover.
|
|
516
|
+
label: k === 0 ? `after ${shownType(r)} reported` : null,
|
|
517
|
+
title: `after ${shownType(r)} reported\n${INFERRED_NOTE}`,
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
for (const m of waves[i].members) at.get(m.id).after = reporters.map((r) => shownType(r));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
items, edges, width: Math.max(box.width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
|
|
525
|
+
drawn: agents.length, density: compact ? 'compact' : 'comfortable', group: 'order', stacked: false, aside: false,
|
|
526
|
+
waves: waves.length, sessionY,
|
|
404
527
|
};
|
|
405
528
|
}
|
|
406
529
|
|
|
@@ -432,6 +555,42 @@ export function fitZoom(width, height, paneW, paneH) {
|
|
|
432
555
|
return Math.min(ZOOM.max, Math.max(ZOOM.min, k));
|
|
433
556
|
}
|
|
434
557
|
|
|
558
|
+
/* --------------------------------------------------------------- settle --- */
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* What the transcript cannot say and the live session can.
|
|
562
|
+
*
|
|
563
|
+
* An agent started in the background can sit inside one tool call for twenty minutes and write nothing. The
|
|
564
|
+
* server, reading files, calls that `stale` or `ended`; it cannot tell it from an agent whose session was closed
|
|
565
|
+
* under it. The machine can: while the session is running, an agent whose transcript stops in the middle of a
|
|
566
|
+
* turn is working. So is a command sent to the background that no notice has ended.
|
|
567
|
+
*
|
|
568
|
+
* @param graph the server's { nodes, stats }
|
|
569
|
+
* @param live true when the machine reports the session as running; anything else changes nothing
|
|
570
|
+
* @returns the graph to draw: the same object when nothing in it needed saying differently
|
|
571
|
+
*/
|
|
572
|
+
export function settle(graph, live) {
|
|
573
|
+
const nodes = graph?.nodes ?? [];
|
|
574
|
+
const quiet = (n) => n.kind === 'agent' && n.background && n.midTurn && (n.status === 'stale' || n.status === 'ended');
|
|
575
|
+
const session = nodes.find((n) => n.kind === 'session');
|
|
576
|
+
const commands = live === true ? (session?.backgroundCommands ?? []) : [];
|
|
577
|
+
if (live !== true || !nodes.some(quiet)) {
|
|
578
|
+
if (!session || (session.backgroundNow ?? []).length === commands.length) {
|
|
579
|
+
return session ? { ...graph, nodes: nodes.map((n) => (n === session ? { ...n, backgroundNow: commands } : n)) } : graph;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const byStatus = { ...(graph.stats?.byStatus ?? {}) };
|
|
583
|
+
const out = nodes.map((n) => {
|
|
584
|
+
if (n === session) return { ...n, backgroundNow: commands };
|
|
585
|
+
if (live !== true || !quiet(n)) return n;
|
|
586
|
+
byStatus[n.status] -= 1;
|
|
587
|
+
if (!byStatus[n.status]) delete byStatus[n.status];
|
|
588
|
+
byStatus.running = (byStatus.running ?? 0) + 1;
|
|
589
|
+
return { ...n, status: 'running', quiet: true };
|
|
590
|
+
});
|
|
591
|
+
return { ...graph, nodes: out, stats: graph.stats ? { ...graph.stats, byStatus } : graph.stats };
|
|
592
|
+
}
|
|
593
|
+
|
|
435
594
|
/* ------------------------------------------------------------ attention --- */
|
|
436
595
|
|
|
437
596
|
/**
|
|
@@ -445,11 +604,12 @@ export function attention(nodes, waiting = new Set()) {
|
|
|
445
604
|
const needs = agents.filter((n) => waiting.has(n.id)).sort((a, b) => when(b) - when(a));
|
|
446
605
|
const failed = agents.filter((n) => !waiting.has(n.id) && stateOf(n.status) === 'failed').sort((a, b) => when(b) - when(a));
|
|
447
606
|
return [
|
|
448
|
-
...needs.map((n) => ({ id: n.id, tone: 'waiting', name: typeOf(n), says: 'waiting for you' })),
|
|
607
|
+
...needs.map((n) => ({ id: n.id, tone: 'waiting', name: shownType(n), real: typeOf(n), says: 'waiting for you' })),
|
|
449
608
|
...failed.map((n) => ({
|
|
450
609
|
id: n.id,
|
|
451
610
|
tone: 'fail',
|
|
452
|
-
name:
|
|
611
|
+
name: shownType(n),
|
|
612
|
+
real: typeOf(n),
|
|
453
613
|
says: [n.status, n.errors ? `${n.errors} ${n.errors === 1 ? 'error' : 'errors'}` : null,
|
|
454
614
|
n.workflow ? `in ${n.workflow}` : null].filter(Boolean).join(' · '),
|
|
455
615
|
})),
|
|
@@ -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,44 @@
|
|
|
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');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where an agent stands among the ones the session called, as a tag: "#3 · 09:14". The number is the order of
|
|
37
|
+
* the calls; the time is when the call was made, in the viewer's own zone. An agent the session's transcript did
|
|
38
|
+
* not call has no place to state, and gets no tag.
|
|
39
|
+
* @param clock (ms) => "HH:MM"
|
|
40
|
+
*/
|
|
41
|
+
export function orderTag(n, clock = null) {
|
|
42
|
+
if (n?.order == null) return '';
|
|
43
|
+
return `#${n.order}${clock && n.calledAt != null ? ` · ${clock(n.calledAt)}` : ''}`;
|
|
44
|
+
}
|
package/kit/studio/web/style.css
CHANGED
|
@@ -362,6 +362,9 @@ body.resizing .cv-root { pointer-events: none; }
|
|
|
362
362
|
.sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: none; }
|
|
363
363
|
.srow-sub { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; }
|
|
364
364
|
.srow.remote .srow-sub { padding-left: var(--space-6); }
|
|
365
|
+
/* What the session spent, under its state: time, tokens and the estimate. */
|
|
366
|
+
.srow-usage { padding-left: var(--space-4); flex: initial; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; color: var(--ink-faint); }
|
|
367
|
+
.srow.remote .srow-usage { padding-left: var(--space-6); }
|
|
365
368
|
.sub.bad, .nav-line.bad { color: var(--status-bad); }
|
|
366
369
|
mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); color: inherit; border-radius: 2px; }
|
|
367
370
|
|
|
@@ -722,6 +725,23 @@ button.tl-lab { cursor: pointer; }
|
|
|
722
725
|
.att-says { color: var(--ink-dim); }
|
|
723
726
|
.attention-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
724
727
|
|
|
728
|
+
/* A session waiting on its own terminal: said where an approval would be, with no answers, because there are
|
|
729
|
+
none to give from here. */
|
|
730
|
+
.terminal-wait {
|
|
731
|
+
flex: none; min-width: 0;
|
|
732
|
+
display: flex; align-items: baseline; gap: var(--space-2);
|
|
733
|
+
padding: var(--space-2) var(--space-4);
|
|
734
|
+
border-top: 1px solid var(--line); background: var(--surface);
|
|
735
|
+
font-size: 13px;
|
|
736
|
+
}
|
|
737
|
+
.terminal-wait[hidden] { display: none; }
|
|
738
|
+
.terminal-wait-word { flex: none; font-weight: 600; color: var(--status-waiting); }
|
|
739
|
+
.terminal-wait-what {
|
|
740
|
+
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
741
|
+
font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim);
|
|
742
|
+
}
|
|
743
|
+
.terminal-wait-note { flex: none; margin-left: auto; }
|
|
744
|
+
|
|
725
745
|
/* -------------------------------------------------------------- dock ---
|
|
726
746
|
The approval dock: a row under the canvas, there only while a tool call is
|
|
727
747
|
waiting. It is part of the stage's column, so the canvas above it stays
|
|
@@ -816,6 +836,21 @@ button.tl-lab { cursor: pointer; }
|
|
|
816
836
|
}
|
|
817
837
|
|
|
818
838
|
/* Working. The brightest and the only one that travels. */
|
|
839
|
+
/* A wire that is an inference, not a call: dotted, still, and it can be hovered for what it means. A working wire
|
|
840
|
+
is dashed and moves; this one is neither. */
|
|
841
|
+
.cv-edge[data-kind="inferred"] {
|
|
842
|
+
stroke: var(--ink-faint);
|
|
843
|
+
stroke-dasharray: 1.5 5;
|
|
844
|
+
pointer-events: stroke;
|
|
845
|
+
}
|
|
846
|
+
.cv-edge-label { font-size: 10px; fill: var(--ink-faint); pointer-events: none; }
|
|
847
|
+
/* "#3 · 09:14": where a card stands in the order the session called its agents. */
|
|
848
|
+
.cv-under { display: flex; align-items: baseline; min-width: 0; }
|
|
849
|
+
.cv-under .cv-task { flex: 1 1 auto; min-width: 0; }
|
|
850
|
+
.cv-order { flex: none; margin-left: 6px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
851
|
+
.cv-order[hidden] { display: none; }
|
|
852
|
+
.cv-chip .cv-order, .cv-member .cv-order { margin-left: 0; margin-right: 6px; }
|
|
853
|
+
|
|
819
854
|
.cv-edge[data-state="live"] {
|
|
820
855
|
stroke: var(--status-busy);
|
|
821
856
|
opacity: var(--cv-edge-live);
|
|
@@ -990,6 +1025,8 @@ button.tl-lab { cursor: pointer; }
|
|
|
990
1025
|
}
|
|
991
1026
|
.cv-fold .ic { width: 12px; height: 12px; }
|
|
992
1027
|
.cv-fold:hover { color: var(--ink); background: var(--surface-raised); }
|
|
1028
|
+
/* Open beside itself, the control points back at the card it would fold into. */
|
|
1029
|
+
.cv-group[data-aside="true"] .cv-fold .ic { rotate: 180deg; }
|
|
993
1030
|
|
|
994
1031
|
/* How the group went: one segment per status, as wide as its share. The words
|
|
995
1032
|
are beside it when the group is folded and in its title when it is open. */
|
|
@@ -1052,6 +1089,11 @@ button.tl-lab { cursor: pointer; }
|
|
|
1052
1089
|
cursor: default;
|
|
1053
1090
|
}
|
|
1054
1091
|
.cv-legend[hidden] { display: none; }
|
|
1092
|
+
/* The session's summary is the same kind of box, in the opposite corner. */
|
|
1093
|
+
.cv-spend { right: auto; bottom: auto; left: var(--space-6); top: var(--space-6); width: 220px; }
|
|
1094
|
+
.cv-spend-rows { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: var(--space-1) var(--space-3); font-size: 12px; }
|
|
1095
|
+
.cv-spend-rows dt { color: var(--ink-dim); }
|
|
1096
|
+
.cv-spend-rows dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; color: var(--ink); }
|
|
1055
1097
|
.cv-legend-head { display: flex; align-items: center; justify-content: space-between; }
|
|
1056
1098
|
.cv-legend-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-1) var(--space-2); }
|
|
1057
1099
|
.cv-legend-item { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--ink-dim); white-space: nowrap; }
|
|
@@ -1059,6 +1101,7 @@ button.tl-lab { cursor: pointer; }
|
|
|
1059
1101
|
.cv-legend-line[data-state="live"] { border-top: 1.5px dashed var(--status-busy); }
|
|
1060
1102
|
.cv-legend-line[data-state="waiting"] { border-top: 1.5px dashed var(--status-waiting); }
|
|
1061
1103
|
.cv-legend-line[data-state="failed"] { border-top-color: var(--cv-fail); }
|
|
1104
|
+
.cv-legend-line[data-state="inferred"] { border-top: 1.5px dotted var(--ink-faint); }
|
|
1062
1105
|
.cv-x {
|
|
1063
1106
|
width: 20px; height: 20px; padding: 0; display: inline-flex; align-items: center; justify-content: center;
|
|
1064
1107
|
background: none; border: 0; border-radius: var(--radius-sm); color: var(--ink-dim); cursor: pointer;
|
|
@@ -1076,6 +1119,9 @@ button.tl-lab { cursor: pointer; }
|
|
|
1076
1119
|
}
|
|
1077
1120
|
.cv-tip[hidden] { display: none; }
|
|
1078
1121
|
.cv-tip strong { font-family: var(--font-mono); font-weight: 600; }
|
|
1122
|
+
.cv-real { font-family: var(--font-mono); }
|
|
1123
|
+
/* "× 3": how many agents of one type a card stands for. */
|
|
1124
|
+
.cv-times { flex: none; padding: 0 6px; border-radius: 999px; background: var(--surface-raised); color: var(--ink); font-variant-numeric: tabular-nums; }
|
|
1079
1125
|
|
|
1080
1126
|
/* The small chip the inspector's gate summary is made of. */
|
|
1081
1127
|
.cv-bit {
|
|
@@ -1110,6 +1156,15 @@ button.tl-lab { cursor: pointer; }
|
|
|
1110
1156
|
/* `display: flex` outranks the UA's `[hidden] { display: none }`, so the hidden
|
|
1111
1157
|
panel kept its width. Restate it explicitly. */
|
|
1112
1158
|
.inspector[hidden] { display: none; }
|
|
1159
|
+
/* A command the session left running in the background: what it is, cut to one line, and for how long. */
|
|
1160
|
+
.ibg-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; }
|
|
1161
|
+
.ibg-what { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 12px; }
|
|
1162
|
+
.ibg-row .sub { flex: none; }
|
|
1163
|
+
/* Its left edge is a handle, as the navigator's right edge is: drag to resize, double-click for the default. */
|
|
1164
|
+
.inspector-split {
|
|
1165
|
+
position: absolute; left: -3px; top: 0; bottom: 0; width: 5px; z-index: 6;
|
|
1166
|
+
background: transparent;
|
|
1167
|
+
}
|
|
1113
1168
|
|
|
1114
1169
|
.inspector dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; }
|
|
1115
1170
|
.inspector dt { font-size: 11px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
@@ -1154,6 +1209,9 @@ button.tl-lab { cursor: pointer; }
|
|
|
1154
1209
|
.iname { flex: 1; min-width: 0; margin: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1155
1210
|
.imeta { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4) 0; flex: none; }
|
|
1156
1211
|
.imeta .sub { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
1212
|
+
/* The agent's type as it is declared, beside the role it is shown as. */
|
|
1213
|
+
.imeta { flex-wrap: wrap; }
|
|
1214
|
+
.ireal { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1157
1215
|
/* The task in full: it is the one place the whole sentence is shown. */
|
|
1158
1216
|
.itask { padding: var(--space-3) var(--space-4); font-size: 16px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; flex: none; max-height: 30%; overflow-y: auto; }
|
|
1159
1217
|
.itask:empty { padding-bottom: 0; }
|
|
@@ -1587,15 +1645,10 @@ body.vresizing { cursor: row-resize; user-select: none; }
|
|
|
1587
1645
|
|
|
1588
1646
|
/* --------------------------------------------------------- full screen --- */
|
|
1589
1647
|
|
|
1590
|
-
/* Full screen
|
|
1591
|
-
the inspector and the conversation
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
:root:fullscreen .shell > .side-rail,
|
|
1595
|
-
:root:fullscreen .shell > .resizer,
|
|
1596
|
-
:root:fullscreen .shell > .inspector,
|
|
1597
|
-
:root:fullscreen .shell > .chat,
|
|
1598
|
-
:root:fullscreen .shell > .newpanel { display: none; }
|
|
1648
|
+
/* Full screen takes the browser away and nothing of the panel: the navigator,
|
|
1649
|
+
the inspector and the conversation stay where they were, and open and close
|
|
1650
|
+
as they do in a window. A rule here used to remove them, and with them the
|
|
1651
|
+
only way to an agent's details or to a conversation that was waiting. */
|
|
1599
1652
|
|
|
1600
1653
|
/* ------------------------------------------------- tab bar that fits --- */
|
|
1601
1654
|
|
|
@@ -1627,7 +1680,7 @@ body.vresizing { cursor: row-resize; user-select: none; }
|
|
|
1627
1680
|
|
|
1628
1681
|
@media (min-width: 1280px) {
|
|
1629
1682
|
/* Docked: back in the grid, in the column kept for it. */
|
|
1630
|
-
.shell.no-chat > .inspector { position:
|
|
1683
|
+
.shell.no-chat > .inspector { position: relative; top: auto; bottom: auto; right: auto; grid-column: 4; box-shadow: none; }
|
|
1631
1684
|
}
|
|
1632
1685
|
|
|
1633
1686
|
@media (max-width: 1279px) {
|
|
@@ -1717,4 +1770,5 @@ body.vresizing { cursor: row-resize; user-select: none; }
|
|
|
1717
1770
|
.shell > .newpanel { grid-column: auto; position: absolute; z-index: 35; inset: 0 0 var(--dock-h, 0px) 0; border-left: none; }
|
|
1718
1771
|
.shell > .inspector,
|
|
1719
1772
|
.shell.no-chat > .inspector { left: 0; right: 0; top: 0; width: auto; border-left: none; }
|
|
1773
|
+
.inspector-split { display: none; }
|
|
1720
1774
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// records: the server's log of approvals, for sessions started here. An agent that failed is drawn failed for
|
|
10
10
|
// its whole length — the transcript says how it ended, not when it started going wrong.
|
|
11
11
|
import { stateOf, tally } from './graph-plan.js';
|
|
12
|
+
import { typeOf, shownType } from './names.js';
|
|
12
13
|
|
|
13
14
|
export const RANGES = [
|
|
14
15
|
{ key: '5m', ms: 5 * 60_000 },
|
|
@@ -24,7 +25,6 @@ export const GROUP_ROWS = { showAllUpTo: 6, shown: 4 };
|
|
|
24
25
|
const SESSION = 'session';
|
|
25
26
|
const TONE = { live: 'busy', waking: 'busy', done: 'done', failed: 'fail', quiet: 'quiet', unknown: 'quiet' };
|
|
26
27
|
|
|
27
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
28
28
|
const isLive = (n) => { const s = stateOf(n.status); return s === 'live' || s === 'waking'; };
|
|
29
29
|
|
|
30
30
|
/** Where an agent's bar ends: its recorded end, or now while it is still going. */
|
|
@@ -113,13 +113,15 @@ export function groupsOf(nodes, grouping) {
|
|
|
113
113
|
const agents = nodes.filter((n) => n.kind === 'agent');
|
|
114
114
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
115
115
|
const key = (n) => {
|
|
116
|
-
if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: typeOf(n) };
|
|
116
|
+
if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: shownType(n), real: typeOf(n) };
|
|
117
117
|
if (grouping === 'parent') {
|
|
118
118
|
const p = byId.get(n.parentId);
|
|
119
119
|
if (!p || p.kind === 'session') return { id: 'direct', label: 'Session · direct' };
|
|
120
|
-
return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${
|
|
120
|
+
return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
|
|
121
121
|
}
|
|
122
122
|
if (grouping === 'none') return { id: 'all', label: null };
|
|
123
|
+
// `order`: the agents called in one assistant message are a wave, and a wave is a group.
|
|
124
|
+
if (grouping === 'order') return n.wave != null ? { id: `wave:${n.wave}`, label: `Wave ${n.wave}` } : { id: 'wave:none', label: 'Not called by the session' };
|
|
123
125
|
// `run`: a workflow run is a group; everything the session started itself is another.
|
|
124
126
|
if (n.workflow) return { id: `run:${n.workflow}`, label: String(n.workflow) };
|
|
125
127
|
return { id: 'direct', label: 'Session · direct' };
|
|
@@ -135,6 +137,12 @@ export function groupsOf(nodes, grouping) {
|
|
|
135
137
|
for (const g of out) g.members.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
|
|
136
138
|
// What the session did itself comes first; the rest in the order they began.
|
|
137
139
|
out.sort((a, b) => Number(b.id === 'direct') - Number(a.id === 'direct') || first(a) - first(b) || a.id.localeCompare(b.id));
|
|
140
|
+
// Waves are in the order they were called, which is not always the order their agents first wrote.
|
|
141
|
+
if (grouping === 'order') {
|
|
142
|
+
const wave = (g) => g.members[0].wave ?? Infinity;
|
|
143
|
+
out.sort((a, b) => wave(a) - wave(b));
|
|
144
|
+
for (const g of out) g.members.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
|
|
145
|
+
}
|
|
138
146
|
return out;
|
|
139
147
|
}
|
|
140
148
|
|
|
@@ -180,13 +188,13 @@ export function rows(nodes, opts = {}) {
|
|
|
180
188
|
for (const m of same) seen.add(m.id);
|
|
181
189
|
if (same.length > 1) {
|
|
182
190
|
body.push({
|
|
183
|
-
kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${
|
|
191
|
+
kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${shownType(n)} × ${same.length}`, real: typeOf(n),
|
|
184
192
|
sub: countsSub(same, waiting), members: same, lanes: lanes(same.flatMap(bar)), indent: headed && g.id !== 'direct',
|
|
185
193
|
});
|
|
186
194
|
} else {
|
|
187
195
|
const sub = agentSub(n, waiting);
|
|
188
196
|
body.push({
|
|
189
|
-
kind: 'agent', id: n.id, group: g.id, node: n, label: typeOf(n), sub: sub.text, tone: sub.tone,
|
|
197
|
+
kind: 'agent', id: n.id, group: g.id, node: n, label: shownType(n), real: typeOf(n), sub: sub.text, tone: sub.tone,
|
|
190
198
|
bars: bar(n), timed: n.startedAt != null, indent: headed && g.id !== 'direct',
|
|
191
199
|
});
|
|
192
200
|
}
|