crewforth 3.1.0-rc.4 → 3.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// The two requests that are not answered with allow or deny: a question the model asks, and a plan it wants
|
|
2
|
+
// approved. This file draws the form for each and hands back what the viewer chose.
|
|
3
|
+
//
|
|
4
|
+
// A form is built once for a request and kept while the request waits. The dock and the List redraw around it every
|
|
5
|
+
// second; a form rebuilt on each of those would lose what was picked, and what was being typed.
|
|
6
|
+
import { answersOf, PLAN_CHOICES } from './approvals.js';
|
|
7
|
+
|
|
8
|
+
function mk(tag, cls, text) {
|
|
9
|
+
const n = document.createElement(tag);
|
|
10
|
+
if (cls) n.className = cls;
|
|
11
|
+
if (text != null) n.textContent = text;
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AskForms {
|
|
16
|
+
/** @param onDecide (item, verdict, extra) => void — 'answer' with { answers }, 'plan' with { mode }, or 'deny' */
|
|
17
|
+
constructor(onDecide) {
|
|
18
|
+
this.onDecide = onDecide ?? (() => {});
|
|
19
|
+
this.forms = new Map(); // request key -> { el, sent(on) }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The form for a request, the same element every time it is asked for. Null for an ordinary tool call. */
|
|
23
|
+
get(item) {
|
|
24
|
+
if (!item?.ask) return null;
|
|
25
|
+
let f = this.forms.get(item.key);
|
|
26
|
+
if (!f) {
|
|
27
|
+
f = item.ask.kind === 'plan' ? this.#plan(item) : this.#questions(item);
|
|
28
|
+
this.forms.set(item.key, f);
|
|
29
|
+
}
|
|
30
|
+
return f.el;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An answer was sent, or could not be delivered: the form's controls go off, or come back. */
|
|
34
|
+
setSent(key, on) { this.forms.get(key)?.sent(on); }
|
|
35
|
+
|
|
36
|
+
/** Forget the forms of requests that are no longer waiting. */
|
|
37
|
+
keep(keys) {
|
|
38
|
+
const alive = new Set(keys);
|
|
39
|
+
for (const k of [...this.forms.keys()]) if (!alive.has(k)) this.forms.delete(k);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#questions(item) {
|
|
43
|
+
const el = mk('div', 'ask');
|
|
44
|
+
const picked = new Map(); // question -> Set of labels
|
|
45
|
+
const typed = new Map(); // question -> the viewer's own text
|
|
46
|
+
const controls = [];
|
|
47
|
+
const send = mk('button', 'btn sm primary', 'Send answers');
|
|
48
|
+
const refresh = () => { send.disabled = send.dataset.sent === '1' || answersOf(item.ask, picked, typed) === null; };
|
|
49
|
+
|
|
50
|
+
for (const q of item.ask.questions) {
|
|
51
|
+
picked.set(q.question, new Set());
|
|
52
|
+
const box = mk('div', 'ask-q');
|
|
53
|
+
const head = mk('div', 'ask-head');
|
|
54
|
+
if (q.header) head.append(mk('span', 'ask-tag', q.header));
|
|
55
|
+
head.append(mk('span', 'ask-text', q.question));
|
|
56
|
+
if (q.multiSelect) head.append(mk('span', 'sub', 'choose any'));
|
|
57
|
+
const opts = mk('div', 'ask-opts');
|
|
58
|
+
opts.setAttribute('role', q.multiSelect ? 'group' : 'radiogroup');
|
|
59
|
+
const buttons = [];
|
|
60
|
+
for (const o of q.options) {
|
|
61
|
+
const b = mk('button', 'ask-opt', o.label);
|
|
62
|
+
b.type = 'button';
|
|
63
|
+
b.title = o.description;
|
|
64
|
+
b.setAttribute('role', q.multiSelect ? 'checkbox' : 'radio');
|
|
65
|
+
b.setAttribute('aria-checked', 'false');
|
|
66
|
+
b.addEventListener('click', () => {
|
|
67
|
+
const set = picked.get(q.question);
|
|
68
|
+
if (q.multiSelect) { if (set.has(o.label)) set.delete(o.label); else set.add(o.label); } else {
|
|
69
|
+
// One answer: choosing an option takes the place of anything typed, and of the option chosen before.
|
|
70
|
+
set.clear(); set.add(o.label);
|
|
71
|
+
own.value = ''; typed.delete(q.question);
|
|
72
|
+
}
|
|
73
|
+
for (const x of buttons) x.el.setAttribute('aria-checked', String(set.has(x.label)));
|
|
74
|
+
refresh();
|
|
75
|
+
});
|
|
76
|
+
buttons.push({ el: b, label: o.label });
|
|
77
|
+
controls.push(b);
|
|
78
|
+
opts.append(b);
|
|
79
|
+
}
|
|
80
|
+
// The model's options do not always hold the answer: the viewer's own words are an answer too.
|
|
81
|
+
const own = mk('input', 'ask-own');
|
|
82
|
+
own.type = 'text';
|
|
83
|
+
own.placeholder = q.multiSelect ? 'Something else, as well…' : 'Something else…';
|
|
84
|
+
own.setAttribute('aria-label', `Your own answer to: ${q.question}`);
|
|
85
|
+
own.addEventListener('input', () => {
|
|
86
|
+
typed.set(q.question, own.value);
|
|
87
|
+
if (!q.multiSelect && own.value.trim()) {
|
|
88
|
+
picked.get(q.question).clear();
|
|
89
|
+
for (const x of buttons) x.el.setAttribute('aria-checked', 'false');
|
|
90
|
+
}
|
|
91
|
+
refresh();
|
|
92
|
+
});
|
|
93
|
+
controls.push(own);
|
|
94
|
+
box.append(head, opts, own);
|
|
95
|
+
el.append(box);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const acts = mk('div', 'ask-acts');
|
|
99
|
+
const dismiss = mk('button', 'btn sm', 'Dismiss');
|
|
100
|
+
dismiss.type = 'button';
|
|
101
|
+
dismiss.title = 'Refuse the question. The model is told nobody answered and may ask in words instead.';
|
|
102
|
+
send.type = 'button';
|
|
103
|
+
send.addEventListener('click', () => {
|
|
104
|
+
const answers = answersOf(item.ask, picked, typed);
|
|
105
|
+
if (answers) this.onDecide(item, 'answer', { answers });
|
|
106
|
+
});
|
|
107
|
+
dismiss.addEventListener('click', () => this.onDecide(item, 'deny', {}));
|
|
108
|
+
controls.push(dismiss);
|
|
109
|
+
acts.append(dismiss, send);
|
|
110
|
+
el.append(acts);
|
|
111
|
+
refresh();
|
|
112
|
+
return { el, sent: (on) => { send.dataset.sent = on ? '1' : '0'; for (const c of controls) c.disabled = on; refresh(); } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#plan(item) {
|
|
116
|
+
const el = mk('div', 'ask');
|
|
117
|
+
const plan = mk('pre', 'ask-plan', item.ask.plan || 'The plan was not included in the request.');
|
|
118
|
+
plan.tabIndex = 0;
|
|
119
|
+
const acts = mk('div', 'ask-acts');
|
|
120
|
+
const keep = mk('button', 'btn sm', 'Keep planning');
|
|
121
|
+
keep.type = 'button';
|
|
122
|
+
keep.title = 'Refuse to leave plan mode. The model stays in it and goes on planning.';
|
|
123
|
+
keep.addEventListener('click', () => this.onDecide(item, 'deny', {}));
|
|
124
|
+
const controls = [keep];
|
|
125
|
+
acts.append(keep);
|
|
126
|
+
PLAN_CHOICES.forEach((c, i) => {
|
|
127
|
+
const b = mk('button', i === PLAN_CHOICES.length - 1 ? 'btn sm primary' : 'btn sm', c.label);
|
|
128
|
+
b.type = 'button';
|
|
129
|
+
b.title = c.title;
|
|
130
|
+
b.addEventListener('click', () => this.onDecide(item, 'plan', { mode: c.mode }));
|
|
131
|
+
controls.push(b);
|
|
132
|
+
acts.append(b);
|
|
133
|
+
});
|
|
134
|
+
el.append(plan, acts);
|
|
135
|
+
return { el, sent: (on) => { for (const c of controls) c.disabled = on; } };
|
|
136
|
+
}
|
|
137
|
+
}
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -11,11 +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, INFERRED_NOTE,
|
|
14
|
+
plan, sequence, crowdFolds, fitZoom, tallestFor, stateOf, ZOOM, GROUPINGS, DENSITIES, INFERRED_NOTE,
|
|
15
15
|
} from './graph-plan.js';
|
|
16
16
|
import { clock } from './timeline-plan.js';
|
|
17
17
|
import { agentStatus } from './nav.js';
|
|
18
|
-
import { roleName, shownType, hoverOf, orderTag } from './names.js';
|
|
18
|
+
import { roleName, shownType, hoverOf, originOf, orderTag, modelOf, modelMix, modelFamily, modelBadge } from './names.js';
|
|
19
|
+
import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
|
|
19
20
|
|
|
20
21
|
// How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
|
|
21
22
|
// and anything a viewer has to wait through is a cost they pay on every spawn.
|
|
@@ -87,12 +88,15 @@ export class Canvas {
|
|
|
87
88
|
this.seq = new Map(); // id -> arrival number (see graph-plan.js)
|
|
88
89
|
this.open = new Map(); // group id -> the viewer's own fold
|
|
89
90
|
this.aside = false; // an agent group opens into the next column, not downward
|
|
91
|
+
this.all = null; // true after Expand all, false after Fold all: what an untouched group is
|
|
90
92
|
// Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
|
|
91
93
|
// so opening a group of 105 does not set 105 animations running at once.
|
|
92
94
|
this.newborn = new Set();
|
|
93
95
|
this.palette = { map: {} };
|
|
96
|
+
this.origins = null;
|
|
94
97
|
this.icons = {};
|
|
95
98
|
this.filter = null; // statuses the page asked to isolate
|
|
99
|
+
this.modelFilter = null; // a family of model the page asked to isolate
|
|
96
100
|
this.waiting = new Set(); // agents parked on an approval
|
|
97
101
|
this.sessionState = null; // { word, tone } for the session card's pill
|
|
98
102
|
this.focusId = null; // show only this branch
|
|
@@ -162,7 +166,7 @@ export class Canvas {
|
|
|
162
166
|
close.innerHTML = MARK.close;
|
|
163
167
|
close.addEventListener('click', () => this.showSpend(false));
|
|
164
168
|
head.append(close);
|
|
165
|
-
this.spendRows = mk('
|
|
169
|
+
this.spendRows = mk('div', 'cv-spend-rows');
|
|
166
170
|
box.append(head, this.spendRows);
|
|
167
171
|
this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
|
|
168
172
|
box.hidden = true; // until there is a session's usage to show
|
|
@@ -175,19 +179,61 @@ export class Canvas {
|
|
|
175
179
|
this.spendEl.hidden = !on || !this.spendCount;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* The rows to show, from usage.js `summaryRows`: { label, value, note, detail }. An empty list hides the box.
|
|
184
|
+
*
|
|
185
|
+
* A row with a `detail` is a control: a click opens the breakdown under it, and it stays open. Rows are kept and
|
|
186
|
+
* updated, never rebuilt — a live session's time changes every second, and a row replaced under a press would
|
|
187
|
+
* lose the click.
|
|
188
|
+
*/
|
|
179
189
|
setSpend(rows) {
|
|
180
|
-
const
|
|
190
|
+
const list = rows ?? [];
|
|
191
|
+
const sig = JSON.stringify(list);
|
|
181
192
|
if (sig === this.spendSig) return;
|
|
182
193
|
this.spendSig = sig;
|
|
183
|
-
this.spendCount =
|
|
184
|
-
this.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
194
|
+
this.spendCount = list.length;
|
|
195
|
+
this.spendParts ??= new Map();
|
|
196
|
+
this.spendShown ??= new Set();
|
|
197
|
+
const alive = new Set();
|
|
198
|
+
for (const r of list) {
|
|
199
|
+
alive.add(r.label);
|
|
200
|
+
let p = this.spendParts.get(r.label);
|
|
201
|
+
if (!p) {
|
|
202
|
+
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') };
|
|
203
|
+
p.row.type = 'button';
|
|
204
|
+
p.row.append(p.label, p.value);
|
|
205
|
+
p.row.addEventListener('click', () => {
|
|
206
|
+
if (!p.row.classList.contains('cv-spend-more')) return;
|
|
207
|
+
if (this.spendShown.has(r.label)) this.spendShown.delete(r.label); else this.spendShown.add(r.label);
|
|
208
|
+
p.detail.hidden = !this.spendShown.has(r.label);
|
|
209
|
+
p.row.setAttribute('aria-expanded', String(!p.detail.hidden));
|
|
210
|
+
});
|
|
211
|
+
this.spendParts.set(r.label, p);
|
|
212
|
+
}
|
|
213
|
+
const more = Boolean(r.detail?.length);
|
|
214
|
+
p.label.textContent = r.label;
|
|
215
|
+
p.value.textContent = r.value ?? '';
|
|
216
|
+
p.row.title = more ? `${r.note ?? ''}\nClick for the breakdown` : (r.note ?? '');
|
|
217
|
+
p.row.classList.toggle('cv-spend-more', more);
|
|
218
|
+
if (more) p.row.setAttribute('aria-expanded', String(this.spendShown.has(r.label))); else p.row.removeAttribute?.('aria-expanded');
|
|
219
|
+
p.detail.hidden = !more || !this.spendShown.has(r.label);
|
|
220
|
+
const dsig = JSON.stringify(r.detail ?? []);
|
|
221
|
+
if (p.dsig !== dsig) {
|
|
222
|
+
p.dsig = dsig;
|
|
223
|
+
p.detail.replaceChildren(...(r.detail ?? []).flatMap(([k, v]) => [mk('dt', null, k), mk('dd', null, v)]));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
for (const [label, p] of this.spendParts) {
|
|
227
|
+
if (alive.has(label)) continue;
|
|
228
|
+
this.spendParts.delete(label);
|
|
229
|
+
}
|
|
230
|
+
// Which rows there are changes rarely (a session's first agent, its first timed turn); what they say changes
|
|
231
|
+
// every second. Only the first puts elements back into the box, and it puts the same elements back.
|
|
232
|
+
const order = list.map((r) => r.label).join('\u0000');
|
|
233
|
+
if (order !== this.spendOrder) {
|
|
234
|
+
this.spendOrder = order;
|
|
235
|
+
this.spendRows.replaceChildren(...list.flatMap((r) => { const p = this.spendParts.get(r.label); return [p.row, p.detail]; }));
|
|
236
|
+
}
|
|
191
237
|
this.spendEl.hidden = !this.spendOpen || !this.spendCount;
|
|
192
238
|
}
|
|
193
239
|
|
|
@@ -223,7 +269,7 @@ export class Canvas {
|
|
|
223
269
|
const crew = mk('span', 'cv-tile cv-tile-sm');
|
|
224
270
|
crew.innerHTML = MARK.kit;
|
|
225
271
|
const a = mk('span', 'cv-legend-item');
|
|
226
|
-
a.append(this.legendBuiltin, mk('span', null, 'Claude Code
|
|
272
|
+
a.append(this.legendBuiltin, mk('span', null, 'Claude Code agent'));
|
|
227
273
|
const b = mk('span', 'cv-legend-item');
|
|
228
274
|
b.append(crew, mk('span', null, 'Crewforth'));
|
|
229
275
|
marks.append(a, b);
|
|
@@ -354,12 +400,20 @@ export class Canvas {
|
|
|
354
400
|
drawn: this.drawn,
|
|
355
401
|
focus: this.focusId ? (roleName(this.nodes.get(this.focusId)?.agentType) || this.focusId) : null,
|
|
356
402
|
groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
|
|
403
|
+
// Whether there is anything left for each control to do. A group inside a folded one is not drawn, and
|
|
404
|
+
// opens with its holder: it is not something "Fold all" has left undone.
|
|
405
|
+
// How many cards the viewer has placed by hand: what "Reset layout" would forget.
|
|
406
|
+
moved: this.pinned.size,
|
|
407
|
+
canExpand: (this.lastPlan?.items ?? []).some((it) => it.kind === 'group' && !it.open),
|
|
408
|
+
canFold: (this.lastPlan?.items ?? []).some((it) => it.kind === 'group' && it.open),
|
|
357
409
|
};
|
|
358
410
|
}
|
|
359
411
|
|
|
360
412
|
/* ---------------------------------------------------------- choices */
|
|
361
413
|
|
|
362
414
|
setPalette(p) { if (p?.map) { this.palette = p; this.#redraw(); } }
|
|
415
|
+
/** Where the session's agents are defined (see `originOf`); null while it has not been read. */
|
|
416
|
+
setOrigins(o) { this.origins = o ?? null; this.#redraw(); }
|
|
363
417
|
|
|
364
418
|
/** The marks that live in files. `builtin` is the markup of web/icons/builtin.svg. */
|
|
365
419
|
setIcons(icons) {
|
|
@@ -376,6 +430,7 @@ export class Canvas {
|
|
|
376
430
|
// Positions set by hand and folds chosen for one grouping say nothing about another.
|
|
377
431
|
this.pinned.clear();
|
|
378
432
|
this.open.clear();
|
|
433
|
+
this.all = null;
|
|
379
434
|
this.sessionY = null;
|
|
380
435
|
this.#crowdFold();
|
|
381
436
|
this.#persist();
|
|
@@ -401,7 +456,17 @@ export class Canvas {
|
|
|
401
456
|
this.#redraw();
|
|
402
457
|
}
|
|
403
458
|
|
|
459
|
+
/** Show only the agents that ran on one family of model (`'opus'`), stepping the rest back. `null` shows all. */
|
|
460
|
+
setModelFilter(family) {
|
|
461
|
+
if ((family ?? null) === this.modelFilter) return;
|
|
462
|
+
this.modelFilter = family ?? null;
|
|
463
|
+
for (const el of this.els.values()) el.sig = null;
|
|
464
|
+
for (const el of this.cellEls.values()) el.sig = null;
|
|
465
|
+
if (this.lastPlan) this.#redraw();
|
|
466
|
+
}
|
|
467
|
+
|
|
404
468
|
dimmed(n) {
|
|
469
|
+
if (n?.kind === 'agent' && this.modelFilter && modelFamily(n.model) !== this.modelFilter) return true;
|
|
405
470
|
if (!this.filter || n?.kind !== 'agent') return false;
|
|
406
471
|
if (this.filterKeepsWaiting && this.waiting.has(n.id)) return false;
|
|
407
472
|
return !this.filter.has(n.status);
|
|
@@ -432,24 +497,23 @@ export class Canvas {
|
|
|
432
497
|
return this.#statusOf(n);
|
|
433
498
|
}
|
|
434
499
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (!more) break;
|
|
445
|
-
}
|
|
446
|
-
this.sessionY = null;
|
|
447
|
-
this.#persist();
|
|
448
|
-
this.#redraw({ fit: true });
|
|
449
|
-
}
|
|
500
|
+
/**
|
|
501
|
+
* Open every group, of every kind, at every depth — and keep doing so: a group that arrives later arrives open.
|
|
502
|
+
* What the viewer then folds by hand stays folded.
|
|
503
|
+
*
|
|
504
|
+
* It used to pass over the cards that stand for agents of one type whenever such a card opens beside itself,
|
|
505
|
+
* on the reasoning that those open one at a time. That left "Expand all" doing nothing in a session whose
|
|
506
|
+
* only groups were of that kind.
|
|
507
|
+
*/
|
|
508
|
+
expandAll() { this.#setAll(true); }
|
|
450
509
|
|
|
451
|
-
|
|
452
|
-
|
|
510
|
+
/** Fold every group, and keep doing so for the ones that arrive later. */
|
|
511
|
+
foldAll() { this.#setAll(false); }
|
|
512
|
+
|
|
513
|
+
#setAll(open) {
|
|
514
|
+
this.all = open;
|
|
515
|
+
// Every group follows the one choice; what was opened or folded by hand before it is forgotten.
|
|
516
|
+
this.open.clear();
|
|
453
517
|
this.sessionY = null;
|
|
454
518
|
this.#persist();
|
|
455
519
|
this.#redraw({ fit: true });
|
|
@@ -462,20 +526,39 @@ export class Canvas {
|
|
|
462
526
|
this.#redraw({ fit: true });
|
|
463
527
|
}
|
|
464
528
|
|
|
465
|
-
/**
|
|
529
|
+
/**
|
|
530
|
+
* Forget every hand-placed card and lay the graph out again. Only the positions go: what is selected, what is
|
|
531
|
+
* filtered, the zoom, and which groups are open stay as they are. This session's saved layout loses its
|
|
532
|
+
* positions with it, and no other session's is touched.
|
|
533
|
+
* @returns what was forgotten, for `restoreLayout` to put back; null when no card had been moved
|
|
534
|
+
*/
|
|
466
535
|
resetLayout() {
|
|
536
|
+
if (!this.pinned.size) return null;
|
|
537
|
+
const was = { pins: new Map(this.pinned), sessionY: this.sessionY, session: this.sessionKey, group: this.group };
|
|
467
538
|
this.pinned.clear();
|
|
468
539
|
this.sessionY = null;
|
|
469
|
-
this.touched = false;
|
|
470
540
|
this.#persist();
|
|
471
|
-
this.#redraw(
|
|
541
|
+
this.#redraw();
|
|
542
|
+
this.onChange?.(this.state());
|
|
543
|
+
return was;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Put back the positions `resetLayout` returned. Nothing happens if the session or the grouping has changed. */
|
|
547
|
+
restoreLayout(was) {
|
|
548
|
+
if (!was || was.session !== this.sessionKey || was.group !== this.group) return false;
|
|
549
|
+
this.pinned = new Map(was.pins);
|
|
550
|
+
this.sessionY = was.sessionY;
|
|
551
|
+
this.#persist();
|
|
552
|
+
this.#redraw();
|
|
553
|
+
this.onChange?.(this.state());
|
|
554
|
+
return true;
|
|
472
555
|
}
|
|
473
556
|
|
|
474
557
|
/* ----------------------------------------------------------- layout */
|
|
475
558
|
|
|
476
559
|
#crowdFold() {
|
|
477
560
|
const nodes = this.#visibleNodes();
|
|
478
|
-
for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq })) {
|
|
561
|
+
for (const id of crowdFolds(nodes, { group: this.group, density: this.density, seq: this.seq, tallest: this.#tallest() })) {
|
|
479
562
|
if (!this.open.has(id)) this.open.set(id, false);
|
|
480
563
|
}
|
|
481
564
|
}
|
|
@@ -494,13 +577,22 @@ export class Canvas {
|
|
|
494
577
|
#plan() {
|
|
495
578
|
return plan(this.#visibleNodes(), {
|
|
496
579
|
group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
|
|
497
|
-
aside: this.aside,
|
|
580
|
+
aside: this.aside, all: this.all, tallest: this.#tallest(),
|
|
498
581
|
});
|
|
499
582
|
}
|
|
500
583
|
|
|
584
|
+
/** How tall a picture may be here before Auto turns cards into chips: what this pane fits, read off the pane. */
|
|
585
|
+
#tallest() {
|
|
586
|
+
this.paneHeight = this.root.getBoundingClientRect?.().height ?? 0;
|
|
587
|
+
return tallestFor(this.paneHeight);
|
|
588
|
+
}
|
|
589
|
+
|
|
501
590
|
/** Re-fit only while the view is still the canvas's own. Once the viewer has panned, zoomed or placed a card,
|
|
502
591
|
* moving the view under them is not helpful. */
|
|
503
592
|
fitIfUntouched() {
|
|
593
|
+
// The pane changed size: what fits in it may have changed with it, and so may Auto's answer.
|
|
594
|
+
const h = this.root.getBoundingClientRect?.().height ?? 0;
|
|
595
|
+
if (this.lastPlan && this.density === 'auto' && tallestFor(h) !== tallestFor(this.paneHeight ?? 0)) this.#redraw();
|
|
504
596
|
if (!this.touched && !this.pinned.size) this.fit();
|
|
505
597
|
}
|
|
506
598
|
|
|
@@ -522,6 +614,7 @@ export class Canvas {
|
|
|
522
614
|
if (this.sessionKey === sessionId) return;
|
|
523
615
|
this.sessionKey = sessionId;
|
|
524
616
|
this.nodes.clear(); this.pos.clear(); this.pinned.clear(); this.open.clear(); this.seq.clear();
|
|
617
|
+
this.all = null;
|
|
525
618
|
this.els.clear(); this.cellEls.clear(); this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
|
|
526
619
|
this.edgeEls.clear(); this.newborn.clear();
|
|
527
620
|
this.mapRects.clear(); this.mapBoxes.replaceChildren();
|
|
@@ -667,7 +760,11 @@ export class Canvas {
|
|
|
667
760
|
row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
|
|
668
761
|
p.name = mk('div', 'cv-name cv-indent');
|
|
669
762
|
p.sub = mk('div', 'cv-sub cv-indent');
|
|
670
|
-
|
|
763
|
+
// What it has running in the background takes a line of its own, when there is any: on the line above it
|
|
764
|
+
// ran out of the card.
|
|
765
|
+
p.bg = mk('div', 'cv-sub cv-indent cv-bg');
|
|
766
|
+
p.bg.hidden = true;
|
|
767
|
+
el.append(row, p.name, p.sub, p.bg);
|
|
671
768
|
} else if (it.kind === 'group') {
|
|
672
769
|
el.classList.add('cv-group');
|
|
673
770
|
p.tile = mk('span', 'cv-tile');
|
|
@@ -685,7 +782,10 @@ export class Canvas {
|
|
|
685
782
|
} else {
|
|
686
783
|
const row = mk('div', 'cv-row cv-indent');
|
|
687
784
|
row.append(p.bar, p.count);
|
|
688
|
-
|
|
785
|
+
// What its members add up to, and what they ran on. At compact density the card has no room for them.
|
|
786
|
+
p.totals = mk('div', 'cv-sub cv-indent cv-totals');
|
|
787
|
+
p.mix = mk('div', 'cv-sub cv-indent cv-mix');
|
|
788
|
+
el.append(p.head, row, p.totals, p.mix);
|
|
689
789
|
}
|
|
690
790
|
p.fold.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(it.id); });
|
|
691
791
|
} else if (it.compact) {
|
|
@@ -694,8 +794,9 @@ export class Canvas {
|
|
|
694
794
|
p.order = mk('span', 'cv-order');
|
|
695
795
|
p.name = mk('span', 'cv-name');
|
|
696
796
|
p.task = mk('span', 'cv-task');
|
|
797
|
+
p.model = mk('span', 'cv-model');
|
|
697
798
|
p.word = mk('span', 'cv-word');
|
|
698
|
-
el.append(p.dot, p.order, p.name, p.task, p.word);
|
|
799
|
+
el.append(p.dot, p.order, p.name, p.task, p.model, p.word);
|
|
699
800
|
} else {
|
|
700
801
|
p.tile = mk('span', 'cv-tile');
|
|
701
802
|
p.name = mk('span', 'cv-name');
|
|
@@ -708,7 +809,12 @@ export class Canvas {
|
|
|
708
809
|
p.task = mk('span', 'cv-task');
|
|
709
810
|
const under = mk('div', 'cv-under cv-indent');
|
|
710
811
|
under.append(p.task, p.order);
|
|
711
|
-
|
|
812
|
+
// The third line: what it ran on, how long it took and what it spent.
|
|
813
|
+
p.model = mk('span', 'cv-model');
|
|
814
|
+
p.facts = mk('span', 'cv-facts');
|
|
815
|
+
const facts = mk('div', 'cv-under cv-indent cv-third');
|
|
816
|
+
facts.append(p.model, p.facts);
|
|
817
|
+
el.append(row, under, facts);
|
|
712
818
|
}
|
|
713
819
|
el.parts = p;
|
|
714
820
|
}
|
|
@@ -731,28 +837,33 @@ export class Canvas {
|
|
|
731
837
|
}
|
|
732
838
|
|
|
733
839
|
/**
|
|
734
|
-
* Whose agent this is
|
|
735
|
-
*
|
|
736
|
-
*
|
|
840
|
+
* Whose agent this is, and there are two answers. Crewforth's: named `crew-…` and in its agents directory,
|
|
841
|
+
* which is what the palette's map holds. Everything else is a Claude Code agent — built in, or defined by the
|
|
842
|
+
* project, the user or a plugin; this panel keeps no list of those, so a type Claude Code adds tomorrow is
|
|
843
|
+
* drawn like the ones it has today.
|
|
844
|
+
*
|
|
845
|
+
* One thing can be unknown: whether a `crew-…` agent is Crewforth's, when its agents could not be read.
|
|
846
|
+
* It is then drawn with the common mark and says what was not measured. A type that is not `crew-…` needs no
|
|
847
|
+
* palette to be placed.
|
|
737
848
|
*/
|
|
738
849
|
#identity(n) {
|
|
739
|
-
|
|
740
|
-
|
|
850
|
+
const type = n?.agentType ?? '';
|
|
851
|
+
if (this.palette.map?.[type]?.source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
|
|
852
|
+
if (this.palette.measured === false && type.startsWith('crew-')) {
|
|
853
|
+
return { source: 'agent', title: `Agent identity not measured — ${this.palette.reason ?? 'no reason given'}` };
|
|
741
854
|
}
|
|
742
|
-
|
|
743
|
-
if (source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
|
|
744
|
-
if (source === 'builtin') return { source: 'builtin', title: 'Claude Code built-in agent' };
|
|
745
|
-
return { source: 'unknown', title: 'Not declared by Crewforth or by Claude Code — an agent type this panel does not recognise' };
|
|
855
|
+
return { source: 'agent', title: 'Claude Code agent', where: originOf(type, this.origins) };
|
|
746
856
|
}
|
|
747
857
|
|
|
748
858
|
#fillTile(tile, n) {
|
|
749
859
|
const who = this.#identity(n);
|
|
750
|
-
const sig = `${who.source}|${who.title}|${this.icons.builtin ? 1 : 0}`;
|
|
860
|
+
const sig = `${who.source}|${who.title}|${who.where ?? ''}|${this.icons.builtin ? 1 : 0}`;
|
|
751
861
|
if (tile.sig === sig) return who;
|
|
752
862
|
tile.sig = sig;
|
|
753
863
|
tile.innerHTML = who.source === 'kit' ? MARK.kit : (this.icons.builtin ?? '');
|
|
754
864
|
tile.dataset.source = who.source;
|
|
755
|
-
|
|
865
|
+
// Whose it is, and under that where it is defined, once that has been read.
|
|
866
|
+
tile.title = who.where ? `${who.title}\n${who.where}` : who.title;
|
|
756
867
|
return who;
|
|
757
868
|
}
|
|
758
869
|
|
|
@@ -769,12 +880,16 @@ export class Canvas {
|
|
|
769
880
|
p.pill.dot.dataset.tone = st?.tone ?? 'none';
|
|
770
881
|
p.pill.word.textContent = st?.word ?? '';
|
|
771
882
|
p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
|
|
883
|
+
// Cut to the card's width; hovering says it whole.
|
|
884
|
+
p.name.title = p.name.textContent;
|
|
772
885
|
const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
|
|
773
886
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
|
|
774
|
-
// What the session has running that is not an agent: commands sent to the background.
|
|
775
|
-
if (bg) bits.push(`${bg} in background`);
|
|
776
887
|
p.sub.textContent = bits.join(' · ');
|
|
777
|
-
p.sub.title =
|
|
888
|
+
p.sub.title = p.sub.textContent;
|
|
889
|
+
// What the session has running that is not an agent: commands sent to the background.
|
|
890
|
+
p.bg.hidden = !bg;
|
|
891
|
+
p.bg.textContent = bg ? `${bg} in background` : '';
|
|
892
|
+
p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
|
|
778
893
|
el.title = 'Click to inspect this session';
|
|
779
894
|
el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
|
|
780
895
|
}
|
|
@@ -787,7 +902,9 @@ export class Canvas {
|
|
|
787
902
|
const dim = this.dimmed(n);
|
|
788
903
|
// Its place in the order the session called its agents, and when: "#3 · 09:14". A chip has room for the number.
|
|
789
904
|
const tag = orderTag(n, compact ? null : clock);
|
|
790
|
-
const
|
|
905
|
+
const mdl = modelOf(n);
|
|
906
|
+
const facts = compact ? '' : factsOf(n);
|
|
907
|
+
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status, tag, after, modelBadge(n), facts]);
|
|
791
908
|
if (el.sig === sig) return;
|
|
792
909
|
el.sig = sig;
|
|
793
910
|
const p = el.parts;
|
|
@@ -804,6 +921,12 @@ export class Canvas {
|
|
|
804
921
|
p.task.title = task;
|
|
805
922
|
p.order.textContent = tag;
|
|
806
923
|
p.order.hidden = !tag;
|
|
924
|
+
// The model it ran on, in full at every density: on a chip it is the task that gives way, not the model.
|
|
925
|
+
p.model.textContent = modelBadge(n);
|
|
926
|
+
p.model.hidden = !mdl.ran;
|
|
927
|
+
p.model.title = mdl.title;
|
|
928
|
+
p.model.dataset.differs = String(mdl.differs);
|
|
929
|
+
if (!compact) { p.facts.textContent = facts; p.facts.title = factsTitle(n); }
|
|
807
930
|
p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
|
|
808
931
|
// In the Order view: what had reported before this wave was called. A reading of the order of events.
|
|
809
932
|
el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
|
|
@@ -824,7 +947,7 @@ export class Canvas {
|
|
|
824
947
|
const p = el.parts;
|
|
825
948
|
const counts = it.counts;
|
|
826
949
|
const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
|
|
827
|
-
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status, it.first?.order ?? null]);
|
|
950
|
+
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)]);
|
|
828
951
|
if (el.sig !== sig) {
|
|
829
952
|
el.sig = sig;
|
|
830
953
|
el.dataset.state = stateOf(it.status);
|
|
@@ -857,7 +980,11 @@ export class Canvas {
|
|
|
857
980
|
return seg;
|
|
858
981
|
}));
|
|
859
982
|
p.bar.title = says;
|
|
860
|
-
|
|
983
|
+
// Beside the bar: how the members went — or, at compact density where the card has no line for it, the
|
|
984
|
+
// models they ran on, with how they went left to the bar and its tooltip.
|
|
985
|
+
const mix = modelMix(it.members).replace(/, /g, ' \u00b7 ');
|
|
986
|
+
if (!it.boxed) { p.count.textContent = it.compact && mix ? mix : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on` : ''; }
|
|
987
|
+
if (!it.boxed) this.#fillTotals(p, it);
|
|
861
988
|
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
862
989
|
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
863
990
|
}
|
|
@@ -911,13 +1038,30 @@ export class Canvas {
|
|
|
911
1038
|
c.parts.name.textContent = text;
|
|
912
1039
|
c.parts.order.textContent = ctag;
|
|
913
1040
|
c.parts.order.hidden = !ctag;
|
|
914
|
-
c.title = hoverOf(n);
|
|
1041
|
+
c.title = [hoverOf(n), [modelOf(n).ran, factsOf(n)].filter(Boolean).join(' \u00b7 ')].filter(Boolean).join('\n');
|
|
915
1042
|
c.parts.dot.dataset.tone = st.tone ?? 'none';
|
|
916
1043
|
c.parts.word.textContent = st.word;
|
|
917
1044
|
c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
|
|
918
1045
|
}
|
|
919
1046
|
}
|
|
920
1047
|
|
|
1048
|
+
/** A group as one card: the time, the tokens and the estimate its members add up to, and the models they ran on. */
|
|
1049
|
+
#fillTotals(p, it) {
|
|
1050
|
+
const s = it.sum ?? {};
|
|
1051
|
+
p.totals.hidden = Boolean(it.compact);
|
|
1052
|
+
p.mix.hidden = Boolean(it.compact);
|
|
1053
|
+
if (it.compact) return;
|
|
1054
|
+
p.totals.textContent = [
|
|
1055
|
+
s.timed ? fmtDuration(s.durationMs) : null,
|
|
1056
|
+
s.counted ? `${fmtCount(s.fresh)} new` : null,
|
|
1057
|
+
s.counted ? `${fmtCount(s.cacheRead)} cache` : null,
|
|
1058
|
+
s.counted ? fmtCost(s.cost) : null,
|
|
1059
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
1060
|
+
p.totals.title = `${it.members.length} calls. Time, new tokens, tokens read from the cache, and cost: ${COST_NOTE}`;
|
|
1061
|
+
p.mix.textContent = modelMix(it.members);
|
|
1062
|
+
p.mix.title = 'The models its agents ran on';
|
|
1063
|
+
}
|
|
1064
|
+
|
|
921
1065
|
/** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
|
|
922
1066
|
#edgeWords(path, e, c) {
|
|
923
1067
|
const title = e.title ?? '';
|
|
@@ -1102,7 +1246,8 @@ export class Canvas {
|
|
|
1102
1246
|
if (!it || it.kind !== 'group') return;
|
|
1103
1247
|
this.open.set(groupId, !it.open);
|
|
1104
1248
|
// Beside itself, one agent's tasks at a time: a second column of them from two groups reads as one list.
|
|
1105
|
-
|
|
1249
|
+
// After "Expand all" that is not the rule: every group is open because the viewer asked for all of them.
|
|
1250
|
+
if (!it.open && it.type === 'type' && this.aside && this.all !== true) {
|
|
1106
1251
|
for (const other of this.lastPlan.items) if (other.kind === 'group' && other.type === 'type' && other.id !== groupId) this.open.set(other.id, false);
|
|
1107
1252
|
}
|
|
1108
1253
|
this.sessionY = null;
|
|
@@ -1298,6 +1443,7 @@ export class Canvas {
|
|
|
1298
1443
|
group: this.group,
|
|
1299
1444
|
pins: Object.fromEntries([...this.pinned].map(([id, p]) => [id, [p.x, p.y]])),
|
|
1300
1445
|
open: Object.fromEntries(this.open),
|
|
1446
|
+
all: this.all,
|
|
1301
1447
|
}));
|
|
1302
1448
|
} catch { /* kept for this page view only */ }
|
|
1303
1449
|
}
|
|
@@ -1311,6 +1457,7 @@ export class Canvas {
|
|
|
1311
1457
|
if (GROUPINGS.includes(saved.group)) this.group = saved.group;
|
|
1312
1458
|
for (const [id, [x, y]] of Object.entries(saved.pins ?? {})) this.pinned.set(id, { x, y });
|
|
1313
1459
|
for (const [id, on] of Object.entries(saved.open ?? {})) this.open.set(id, Boolean(on));
|
|
1460
|
+
this.all = typeof saved.all === 'boolean' ? saved.all : null;
|
|
1314
1461
|
} catch { /* start from the canvas's own layout */ }
|
|
1315
1462
|
}
|
|
1316
1463
|
}
|
|
@@ -1330,6 +1477,20 @@ function readPref(key, allowed, fallback) {
|
|
|
1330
1477
|
return allowed.includes(v) ? v : fallback;
|
|
1331
1478
|
}
|
|
1332
1479
|
|
|
1480
|
+
/** An agent's third line: how long it took and what it spent. */
|
|
1481
|
+
function factsOf(n) {
|
|
1482
|
+
return [
|
|
1483
|
+
n.durationMs != null ? fmtDuration(n.durationMs) : null,
|
|
1484
|
+
n.usage ? `${fmtCount(n.usage.tokens.fresh)} new` : null,
|
|
1485
|
+
n.usage ? fmtCost(n.usage.cost) : null,
|
|
1486
|
+
].filter(Boolean).join(' \u00b7 ');
|
|
1487
|
+
}
|
|
1488
|
+
function factsTitle(n) {
|
|
1489
|
+
if (!n.usage) return '';
|
|
1490
|
+
const t = n.usage.tokens;
|
|
1491
|
+
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}`;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1333
1494
|
function fmtTokens(t) {
|
|
1334
1495
|
if (t == null) return '';
|
|
1335
1496
|
if (t < 1000) return `${t}`;
|