crewforth 3.1.0-rc.1 → 3.1.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -2
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +98 -2
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +36 -5
- package/kit/skills/frontend-flutter/SKILL.md +39 -9
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +29 -3
- package/kit/skills/frontend-flutter/references/platform-async-release.md +41 -0
- package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +110 -11
- package/kit/studio/server/lib/permissions.js +11 -3
- package/kit/studio/server/lib/pricing.js +63 -0
- package/kit/studio/server/lib/usage.js +144 -0
- package/kit/studio/web/app.js +158 -37
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +95 -20
- package/kit/studio/web/chat.js +1 -0
- package/kit/studio/web/convo.js +4 -2
- package/kit/studio/web/dock.js +1 -0
- package/kit/studio/web/graph-plan.js +67 -12
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +14 -1
- package/kit/studio/web/list-plan.js +4 -4
- package/kit/studio/web/list.js +2 -0
- package/kit/studio/web/names.js +33 -0
- package/kit/studio/web/style.css +48 -10
- package/kit/studio/web/timeline-plan.js +5 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
package/kit/studio/web/app.js
CHANGED
|
@@ -15,15 +15,17 @@ import {
|
|
|
15
15
|
liveSessions, machines, seenAgo, isFirstRun,
|
|
16
16
|
} from './nav.js';
|
|
17
17
|
import { liveness, offlineNote } from './liveness.js';
|
|
18
|
-
import { attention, AUTO } from './graph-plan.js';
|
|
19
|
-
import {
|
|
18
|
+
import { attention, settle, AUTO } from './graph-plan.js';
|
|
19
|
+
import { summaryRows, usageLine, costNote } from './usage.js';
|
|
20
|
+
import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
|
|
20
21
|
import { Dock } from './dock.js';
|
|
21
22
|
import { NewSession } from './newsession.js';
|
|
22
23
|
import { Timeline } from './timeline.js';
|
|
23
24
|
import { List } from './list.js';
|
|
24
25
|
import { defaultView, narrowWarning } from './list-plan.js';
|
|
25
26
|
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
26
|
-
import {
|
|
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
29
|
|
|
28
30
|
const FLEET_POLL_MS = 2000;
|
|
29
31
|
const SESSION_POLL_MS = 5000;
|
|
@@ -54,6 +56,7 @@ const el = {
|
|
|
54
56
|
toast: document.getElementById('toast'),
|
|
55
57
|
attention: document.getElementById('attention'),
|
|
56
58
|
dock: document.getElementById('dock'),
|
|
59
|
+
terminalWait: document.getElementById('terminal-wait'),
|
|
57
60
|
newPanel: document.getElementById('new-panel'),
|
|
58
61
|
tbGroup: document.getElementById('tb-group'),
|
|
59
62
|
tbDensity: document.getElementById('tb-density'),
|
|
@@ -132,6 +135,8 @@ systemLight.addEventListener('change', relabelTheme);
|
|
|
132
135
|
const PANEL = {
|
|
133
136
|
side: { min: 170, max: 620, def: 272, wide: 460, varName: '--side-w', key: 'crewforth-studio-side-w' },
|
|
134
137
|
chat: { min: 280, max: 900, def: 480, wide: 720, varName: '--chat-w', key: 'crewforth-studio-chat-w' },
|
|
138
|
+
// The inspector's default is the stylesheet's own --inspector-w.
|
|
139
|
+
inspector: { min: 280, max: 720, def: 344, varName: '--inspector-w', key: 'crewforth-studio-inspector-w' },
|
|
135
140
|
};
|
|
136
141
|
|
|
137
142
|
function setPanel(which, px, persist = true) {
|
|
@@ -142,7 +147,7 @@ function setPanel(which, px, persist = true) {
|
|
|
142
147
|
return w;
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
for (const which of
|
|
150
|
+
for (const which of Object.keys(PANEL)) {
|
|
146
151
|
setPanel(which, Number(store.get(PANEL[which].key)) || PANEL[which].def, false);
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -209,11 +214,20 @@ function dragPanel(handle, which, edge) {
|
|
|
209
214
|
dragPanel(el.resizer, 'side', 'left');
|
|
210
215
|
dragPanel(el.chatSplit, 'chat', 'right');
|
|
211
216
|
|
|
217
|
+
// The inspector is filled again whenever its agent changes; its handle is made once and put back each time, so a
|
|
218
|
+
// drag is not cut short by a poll.
|
|
219
|
+
const inspectorSplit = document.createElement('div');
|
|
220
|
+
inspectorSplit.className = 'resizer inspector-split';
|
|
221
|
+
inspectorSplit.setAttribute('role', 'separator');
|
|
222
|
+
inspectorSplit.setAttribute('aria-orientation', 'vertical');
|
|
223
|
+
inspectorSplit.setAttribute('aria-label', 'Resize inspector');
|
|
224
|
+
inspectorSplit.tabIndex = 0;
|
|
225
|
+
dragPanel(inspectorSplit, 'inspector', 'right');
|
|
226
|
+
|
|
212
227
|
/* ------------------------------------------------------------ full screen
|
|
213
228
|
The browser's own, so it hides the browser too — a panel meant to be watched
|
|
214
|
-
while work runs should be able to take the whole display. The
|
|
215
|
-
|
|
216
|
-
is left is the canvas; Esc brings everything back. */
|
|
229
|
+
while work runs should be able to take the whole display. The panels keep
|
|
230
|
+
their state: what was open stays open, and `[` and `]` still fold the navigator. */
|
|
217
231
|
|
|
218
232
|
async function toggleFullscreen() {
|
|
219
233
|
try {
|
|
@@ -486,6 +500,11 @@ const pickFrom = (button, words, current, set) => button.addEventListener('click
|
|
|
486
500
|
})));
|
|
487
501
|
});
|
|
488
502
|
pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGroup(g));
|
|
503
|
+
|
|
504
|
+
// In a window with room an agent group opens into the next column; in the band where the navigator is a rail
|
|
505
|
+
// there is no room for another column, and it opens downward as it always did.
|
|
506
|
+
canvas.setAside(!railBand.matches);
|
|
507
|
+
railBand.addEventListener('change', () => canvas.setAside(!railBand.matches));
|
|
489
508
|
pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
|
|
490
509
|
pickFrom(el.tbShow, SHOW, () => show, (key) => setShow(key));
|
|
491
510
|
// The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
|
|
@@ -531,6 +550,10 @@ el.tbOptions.addEventListener('click', (e) => {
|
|
|
531
550
|
if (view === 'graph' && st.groups > 0) {
|
|
532
551
|
items.push({ note: 'Groups' }, { label: 'Expand all', run: () => canvas.expandAll() }, { label: 'Fold all', run: () => canvas.foldAll() });
|
|
533
552
|
}
|
|
553
|
+
if (view === 'graph') {
|
|
554
|
+
const open = canvas.spendOpen;
|
|
555
|
+
items.push({ note: 'Session summary' }, { label: open ? 'Hide time, tokens and cost' : 'Show time, tokens and cost', run: () => canvas.showSpend(!open) });
|
|
556
|
+
}
|
|
534
557
|
openMenu(el.tbOptions, items);
|
|
535
558
|
});
|
|
536
559
|
el.tbExpand.addEventListener('click', () => canvas.expandAll());
|
|
@@ -760,14 +783,18 @@ function paintInspector() {
|
|
|
760
783
|
back.append(icon(ICON.back));
|
|
761
784
|
back.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
|
|
762
785
|
close.classList.add('ihead-close');
|
|
763
|
-
const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : (n
|
|
764
|
-
|
|
786
|
+
const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : shownType(n);
|
|
787
|
+
const iname = node('h3', 'iname', name);
|
|
788
|
+
if (n.kind === 'agent') iname.title = n.agentType ?? '';
|
|
789
|
+
head.append(back, canvas.tileFor(n), iname, close);
|
|
765
790
|
|
|
766
791
|
const meta = node('div', 'imeta');
|
|
767
792
|
const pill = node('span', 'pill');
|
|
768
793
|
// A state nobody read is said, not left as a blank pill.
|
|
769
794
|
pill.append(dot(st.tone), node('span', null, st.word ?? 'State not measured'));
|
|
770
795
|
meta.append(pill, node('span', 'sub', timeLine(n, serverNow())));
|
|
796
|
+
// The role is the name; the type as it is declared is said here, where there is room for it.
|
|
797
|
+
if (n.kind === 'agent' && n.agentType) meta.append(node('code', 'ireal', n.agentType));
|
|
771
798
|
|
|
772
799
|
const task = node('div', 'itask', isSession
|
|
773
800
|
? (sessionLabel(current) ?? n.sessionId ?? '')
|
|
@@ -807,7 +834,7 @@ function paintInspector() {
|
|
|
807
834
|
copy.addEventListener('click', () => copyText(file, 'Copied'));
|
|
808
835
|
foot.append(open, copy);
|
|
809
836
|
|
|
810
|
-
el.inspector.replaceChildren(head, meta, task, tabs, body, foot);
|
|
837
|
+
el.inspector.replaceChildren(inspectorSplit, head, meta, task, tabs, body, foot);
|
|
811
838
|
}
|
|
812
839
|
|
|
813
840
|
function paintAgentOverview(body, n, detail) {
|
|
@@ -886,6 +913,21 @@ function paintSessionOverview(body, n) {
|
|
|
886
913
|
}
|
|
887
914
|
body.append(metaRows(n));
|
|
888
915
|
|
|
916
|
+
// Commands the session sent to the background and that are still going. They are not agents and have no card.
|
|
917
|
+
const running = backgroundLines(n, serverNow());
|
|
918
|
+
if (running.length) {
|
|
919
|
+
const rows = running.map((c) => {
|
|
920
|
+
const row = node('div', 'ibg-row');
|
|
921
|
+
const what = node('code', 'ibg-what', c.what);
|
|
922
|
+
what.title = c.what;
|
|
923
|
+
row.append(what);
|
|
924
|
+
if (c.age) row.append(node('span', 'sub', c.age));
|
|
925
|
+
return row;
|
|
926
|
+
});
|
|
927
|
+
const box = isection('In the background', ...rows);
|
|
928
|
+
body.append(box);
|
|
929
|
+
}
|
|
930
|
+
|
|
889
931
|
const b = kitData?.board;
|
|
890
932
|
let board;
|
|
891
933
|
if (!kitData) board = node('div', 'ihint', 'Reading Crewforth…');
|
|
@@ -1056,7 +1098,7 @@ function paintGates(body) {
|
|
|
1056
1098
|
for (const a of answers.slice(-12)) {
|
|
1057
1099
|
const row = node('div', 'gate-row');
|
|
1058
1100
|
row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString()));
|
|
1059
|
-
row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${a.agentType}` : ''}`));
|
|
1101
|
+
row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${roleName(a.agentType)}` : ''}`));
|
|
1060
1102
|
const v = node('span', 'gate-verdict', OUTCOME_WORD[a.outcome]);
|
|
1061
1103
|
v.dataset.verdict = a.outcome.startsWith('allowed') ? 'ALLOW' : a.outcome === 'answered-elsewhere' ? 'ASK' : 'BLOCK';
|
|
1062
1104
|
row.append(v);
|
|
@@ -1456,6 +1498,14 @@ function sessionRow(project, sn) {
|
|
|
1456
1498
|
|
|
1457
1499
|
const subText = sessionSub(sn, status) + (ownedIds.has(sn.sessionId) ? ' · started here' : '');
|
|
1458
1500
|
if (subText) row.append(marked(subText, 'sub srow-sub'));
|
|
1501
|
+
// What it spent, once the server has read it: time · tokens · ~cost. Asked for a few rows at a time.
|
|
1502
|
+
const spent = usageFor(sn);
|
|
1503
|
+
const line = usageLine(spent);
|
|
1504
|
+
if (line) {
|
|
1505
|
+
const u = node('span', 'sub srow-usage', line);
|
|
1506
|
+
u.title = costNote(spent);
|
|
1507
|
+
row.append(u);
|
|
1508
|
+
}
|
|
1459
1509
|
|
|
1460
1510
|
row.title = `${sn.sessionId}${sn.title ? `\n${sn.title}` : ''}`;
|
|
1461
1511
|
const open = () => selectSession(sn.sessionId);
|
|
@@ -1467,6 +1517,36 @@ function sessionRow(project, sn) {
|
|
|
1467
1517
|
return row;
|
|
1468
1518
|
}
|
|
1469
1519
|
|
|
1520
|
+
/* What each session spent, for its row. The server reads a transcript once and keeps the answer until it grows, so
|
|
1521
|
+
this asks only for rows that are on screen and whose transcript has changed since it last asked. */
|
|
1522
|
+
const usageRows = new Map(); // sessionId -> { at: modifiedAt it was read for, value }
|
|
1523
|
+
const usageWanted = new Set();
|
|
1524
|
+
let usageBusy = false;
|
|
1525
|
+
function usageFor(sn) {
|
|
1526
|
+
const hit = usageRows.get(sn.sessionId);
|
|
1527
|
+
if (!hit || hit.at !== sn.modifiedAt) { usageWanted.add(sn.sessionId); queueMicrotask(pullUsage); }
|
|
1528
|
+
return hit?.value ?? null;
|
|
1529
|
+
}
|
|
1530
|
+
async function pullUsage() {
|
|
1531
|
+
if (usageBusy || !usageWanted.size) return;
|
|
1532
|
+
usageBusy = true;
|
|
1533
|
+
const ids = [...usageWanted].slice(0, 12);
|
|
1534
|
+
for (const id of ids) usageWanted.delete(id);
|
|
1535
|
+
try {
|
|
1536
|
+
const got = await getJson(`/api/usage?ids=${ids.map(encodeURIComponent).join(',')}`);
|
|
1537
|
+
for (const id of ids) {
|
|
1538
|
+
const sn = findSessionRow(id)?.session;
|
|
1539
|
+
// A session the server did not answer for (another machine's) is remembered as not read, and not asked again
|
|
1540
|
+
// until its row changes.
|
|
1541
|
+
usageRows.set(id, { at: sn?.modifiedAt ?? null, value: got.usage?.[id] ?? null });
|
|
1542
|
+
}
|
|
1543
|
+
paintProjects();
|
|
1544
|
+
} catch { /* the Live indicator says so; the rows keep what they had */ } finally {
|
|
1545
|
+
usageBusy = false;
|
|
1546
|
+
if (usageWanted.size) queueMicrotask(pullUsage);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1470
1550
|
function paintProjects() {
|
|
1471
1551
|
const data = projectsData;
|
|
1472
1552
|
if (!data || renaming && document.activeElement?.classList?.contains('srow-rename')) return;
|
|
@@ -1844,6 +1924,7 @@ function paintAttention(nodes) {
|
|
|
1844
1924
|
const b = node('button', 'att');
|
|
1845
1925
|
b.type = 'button';
|
|
1846
1926
|
b.append(dot(a.tone), node('span', 'att-name', a.name), node('span', 'att-says', a.says));
|
|
1927
|
+
b.title = a.real ?? '';
|
|
1847
1928
|
b.addEventListener('click', () => { attentionAt = attentionIds.indexOf(a.id); goTo(a.id); });
|
|
1848
1929
|
return b;
|
|
1849
1930
|
});
|
|
@@ -1900,36 +1981,13 @@ function selectSession(sessionId) {
|
|
|
1900
1981
|
detailCache.clear();
|
|
1901
1982
|
|
|
1902
1983
|
if (source) source.close();
|
|
1984
|
+
lastGraph = null;
|
|
1903
1985
|
source = new EventSource(api(`/api/stream?session=${encodeURIComponent(sessionId)}`));
|
|
1904
1986
|
|
|
1905
1987
|
source.addEventListener('graph', (e) => {
|
|
1906
1988
|
heardNow();
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
lastNodes = g.nodes;
|
|
1910
|
-
timeline.setNodes(g.nodes);
|
|
1911
|
-
list.setNodes(g.nodes);
|
|
1912
|
-
paintAttention(g.nodes);
|
|
1913
|
-
// The conversation's delegation cards and its context figure are the graph's.
|
|
1914
|
-
chat.refresh(sessionId, { contextTokens: g.nodes.find((n) => n.kind === 'session')?.tokens ?? null });
|
|
1915
|
-
// The inspector holds a node object from an earlier frame; refresh it so
|
|
1916
|
-
// status, tokens and tool counts keep moving while it is open.
|
|
1917
|
-
if (inspectorNode) {
|
|
1918
|
-
const fresh = g.nodes.find((n) => n.id === inspectorNode.id);
|
|
1919
|
-
if (fresh) {
|
|
1920
|
-
const changed = fresh.status !== inspectorNode.status;
|
|
1921
|
-
inspectorNode = fresh;
|
|
1922
|
-
paintInspector();
|
|
1923
|
-
if (changed && fresh.kind === 'agent') loadDetail(fresh.id, fresh.status);
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
// Every status is named, so the chips add up to the total. A summary that
|
|
1927
|
-
// reports "250 agents · 7 done" and stops invites the reader to assume the
|
|
1928
|
-
// other 243 failed. A count of records that could not be read stays beside
|
|
1929
|
-
// them: it is not a status, and it is not nothing.
|
|
1930
|
-
const s = g.stats ?? {};
|
|
1931
|
-
paintSummary(s, s.malformed ? [`${s.malformed} malformed`] : []);
|
|
1932
|
-
el.foot.textContent = '';
|
|
1989
|
+
lastGraph = JSON.parse(e.data);
|
|
1990
|
+
showGraph();
|
|
1933
1991
|
});
|
|
1934
1992
|
|
|
1935
1993
|
source.addEventListener('idle', heardNow);
|
|
@@ -2053,6 +2111,8 @@ const outcomes = new Map(); // sessionId -> what became of its requests, as
|
|
|
2053
2111
|
let waitingNow = [];
|
|
2054
2112
|
let waitingSig = '';
|
|
2055
2113
|
let lastNodes = null;
|
|
2114
|
+
let lastGraph = null; // what the stream last sent, before it is settled against the fleet
|
|
2115
|
+
let lastLive = null; // whether the session was live when that was last drawn
|
|
2056
2116
|
|
|
2057
2117
|
function sessionLabel(id) {
|
|
2058
2118
|
const known = findSessionRow(id);
|
|
@@ -2216,6 +2276,67 @@ function paintStageNote() {
|
|
|
2216
2276
|
stageNote.retry.textContent = off ? off.retry : '';
|
|
2217
2277
|
stageNote.retry.hidden = !off;
|
|
2218
2278
|
stageNote.button.hidden = !off;
|
|
2279
|
+
// The fleet can say a session ended, or came back, with no transcript changing.
|
|
2280
|
+
if (lastGraph && sessionIsLive() !== lastLive) showGraph();
|
|
2281
|
+
paintTerminalWait();
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Draw the graph the stream last sent. What the server read from files is settled against what the machine says
|
|
2286
|
+
* of the session first, so this is called again when that changes with no file changing.
|
|
2287
|
+
*/
|
|
2288
|
+
function showGraph() {
|
|
2289
|
+
if (!lastGraph) return;
|
|
2290
|
+
lastLive = sessionIsLive();
|
|
2291
|
+
const g = settle(lastGraph, lastLive);
|
|
2292
|
+
canvas.render(g);
|
|
2293
|
+
canvas.setSpend(summaryRows(g.nodes.find((n) => n.kind === 'session')?.usage ?? null));
|
|
2294
|
+
lastNodes = g.nodes;
|
|
2295
|
+
timeline.setNodes(g.nodes);
|
|
2296
|
+
list.setNodes(g.nodes);
|
|
2297
|
+
paintAttention(g.nodes);
|
|
2298
|
+
// The conversation's delegation cards and its context figure are the graph's.
|
|
2299
|
+
chat.refresh(current, { contextTokens: g.nodes.find((n) => n.kind === 'session')?.tokens ?? null });
|
|
2300
|
+
// The inspector holds a node object from an earlier frame; refresh it so
|
|
2301
|
+
// status, tokens and tool counts keep moving while it is open.
|
|
2302
|
+
if (inspectorNode) {
|
|
2303
|
+
const fresh = g.nodes.find((n) => n.id === inspectorNode.id);
|
|
2304
|
+
if (fresh) {
|
|
2305
|
+
const changed = fresh.status !== inspectorNode.status;
|
|
2306
|
+
inspectorNode = fresh;
|
|
2307
|
+
paintInspector();
|
|
2308
|
+
if (changed && fresh.kind === 'agent') loadDetail(fresh.id, fresh.status);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
// Every status is named, so the chips add up to the total. A summary that
|
|
2312
|
+
// reports "250 agents · 7 done" and stops invites the reader to assume the
|
|
2313
|
+
// other 243 failed. A count of records that could not be read stays beside
|
|
2314
|
+
// them: it is not a status, and it is not nothing.
|
|
2315
|
+
const s = g.stats ?? {};
|
|
2316
|
+
paintSummary(s, s.malformed ? [`${s.malformed} malformed`] : []);
|
|
2317
|
+
el.foot.textContent = '';
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/** A session waiting on its own terminal is said under the view, with what it asks about. */
|
|
2321
|
+
let terminalWaitSig = '';
|
|
2322
|
+
function paintTerminalWait() {
|
|
2323
|
+
const session = lastNodes?.find((n) => n.kind === 'session') ?? null;
|
|
2324
|
+
const w = current
|
|
2325
|
+
? terminalWait(sessionStatus(current, fleetData), session, ownedIds.has(current) || owned.has(current))
|
|
2326
|
+
: null;
|
|
2327
|
+
const sig = w ? `${w.text}|${w.what ?? ''}` : '';
|
|
2328
|
+
if (sig === terminalWaitSig) return;
|
|
2329
|
+
terminalWaitSig = sig;
|
|
2330
|
+
el.terminalWait.hidden = !w;
|
|
2331
|
+
if (w) {
|
|
2332
|
+
const what = node('span', 'terminal-wait-what', w.what ?? '');
|
|
2333
|
+
what.title = w.what ?? '';
|
|
2334
|
+
el.terminalWait.replaceChildren(
|
|
2335
|
+
node('span', 'terminal-wait-word', `${w.text}${w.what ? ':' : ''}`), what,
|
|
2336
|
+
node('span', 'sub terminal-wait-note', 'Studio answers only for sessions started here'),
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
canvas.fitIfUntouched();
|
|
2219
2340
|
}
|
|
2220
2341
|
|
|
2221
2342
|
function retryNow() {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// Every number here is the server's. How long the hook waits (`gateWaitSeconds`), when a request was asked
|
|
8
8
|
// (`askedAt`) and what time it is now (`now`) all come from the machine the hook runs on, so a viewer whose clock
|
|
9
9
|
// differs — a browser on the far end of a tunnel — still counts down to the moment the hook will really give up.
|
|
10
|
+
import { roleName } from './names.js';
|
|
10
11
|
|
|
11
12
|
/** The server's clock, as seen from this page: the difference is measured each time the server says what time it is. */
|
|
12
13
|
export class ServerClock {
|
|
@@ -70,8 +71,26 @@ export function remaining(item, serverNowMs) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/** Who asked, in the words the dock uses. A call from the session itself has no agent, and says so. */
|
|
74
|
+
/**
|
|
75
|
+
* A session started in a terminal that is waiting for its user there. Studio cannot answer for it: the question
|
|
76
|
+
* is Claude Code's own prompt, in that terminal, and no hook of the panel's is in that session.
|
|
77
|
+
*
|
|
78
|
+
* @param status the session's state as the machine reports it (nav.js `sessionStatus`)
|
|
79
|
+
* @param session the graph's session node, whose `openCall` is the newest call with no result
|
|
80
|
+
* @param ownedHere true when the panel started this session: its requests are in the dock instead
|
|
81
|
+
* @returns null, or { text, what } — `what` is the tool and its command when the transcript has them, and
|
|
82
|
+
* otherwise what the CLI itself says it waits for; nothing is made up when neither is there.
|
|
83
|
+
*/
|
|
84
|
+
export function terminalWait(status, session, ownedHere) {
|
|
85
|
+
if (ownedHere || status?.key !== 'waiting') return null;
|
|
86
|
+
const call = session?.openCall ?? null;
|
|
87
|
+
const who = call?.agentType ? `${roleName(call.agentType)} · ` : '';
|
|
88
|
+
const what = call ? `${who}${call.toolName}${call.detail ? ` · ${call.detail}` : ''}` : (status.waitingFor ?? null);
|
|
89
|
+
return { text: 'Waiting for an answer in the terminal', what };
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
export function asker(item) {
|
|
74
|
-
return item.agentType
|
|
93
|
+
return item.agentType ? roleName(item.agentType) : 'Session';
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
export const VERDICTS = {
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
|
|
15
15
|
} from './graph-plan.js';
|
|
16
16
|
import { agentStatus } from './nav.js';
|
|
17
|
+
import { roleName, shownType, hoverOf } from './names.js';
|
|
17
18
|
|
|
18
19
|
// How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
|
|
19
20
|
// and anything a viewer has to wait through is a cost they pay on every spawn.
|
|
@@ -84,6 +85,7 @@ export class Canvas {
|
|
|
84
85
|
this.mapRects = new Map(); // drawn item id -> its box on the minimap
|
|
85
86
|
this.seq = new Map(); // id -> arrival number (see graph-plan.js)
|
|
86
87
|
this.open = new Map(); // group id -> the viewer's own fold
|
|
88
|
+
this.aside = false; // an agent group opens into the next column, not downward
|
|
87
89
|
// Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
|
|
88
90
|
// so opening a group of 105 does not set 105 animations running at once.
|
|
89
91
|
this.newborn = new Set();
|
|
@@ -139,11 +141,53 @@ export class Canvas {
|
|
|
139
141
|
this.mapEl.append(this.mapSvg);
|
|
140
142
|
|
|
141
143
|
this.legendEl = this.#legend();
|
|
144
|
+
this.spendEl = this.#spend();
|
|
142
145
|
this.tipEl = mk('div', 'cv-tip');
|
|
143
146
|
this.tipEl.setAttribute('role', 'tooltip');
|
|
144
147
|
this.tipEl.hidden = true;
|
|
145
148
|
|
|
146
|
-
this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.tipEl);
|
|
149
|
+
this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.spendEl, this.tipEl);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What the session spent: time, tokens and an estimate of the cost. Closed, it stays closed until asked for. */
|
|
153
|
+
#spend() {
|
|
154
|
+
const box = mk('div', 'cv-legend cv-spend');
|
|
155
|
+
box.setAttribute('aria-label', 'Session summary');
|
|
156
|
+
const head = mk('div', 'cv-legend-head');
|
|
157
|
+
head.append(mk('span', 'cv-label', 'Session'));
|
|
158
|
+
const close = mk('button', 'cv-x');
|
|
159
|
+
close.type = 'button';
|
|
160
|
+
close.setAttribute('aria-label', 'Close the session summary');
|
|
161
|
+
close.innerHTML = MARK.close;
|
|
162
|
+
close.addEventListener('click', () => this.showSpend(false));
|
|
163
|
+
head.append(close);
|
|
164
|
+
this.spendRows = mk('dl', 'cv-spend-rows');
|
|
165
|
+
box.append(head, this.spendRows);
|
|
166
|
+
this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
|
|
167
|
+
box.hidden = true; // until there is a session's usage to show
|
|
168
|
+
return box;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
showSpend(on) {
|
|
172
|
+
this.spendOpen = Boolean(on);
|
|
173
|
+
writeFlag('crewforth-studio-spend', on ? 'open' : 'closed');
|
|
174
|
+
this.spendEl.hidden = !on || !this.spendCount;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rows to show: [label, value, note], from usage.js `summaryRows`. An empty list hides the box. */
|
|
178
|
+
setSpend(rows) {
|
|
179
|
+
const sig = JSON.stringify(rows ?? []);
|
|
180
|
+
if (sig === this.spendSig) return;
|
|
181
|
+
this.spendSig = sig;
|
|
182
|
+
this.spendCount = (rows ?? []).length;
|
|
183
|
+
this.spendRows.replaceChildren(...(rows ?? []).flatMap(([label, value, note]) => {
|
|
184
|
+
const dd = mk('dd', null, value ?? '');
|
|
185
|
+
dd.title = note ?? '';
|
|
186
|
+
const dt = mk('dt', null, label);
|
|
187
|
+
dt.title = note ?? '';
|
|
188
|
+
return [dt, dd];
|
|
189
|
+
}));
|
|
190
|
+
this.spendEl.hidden = !this.spendOpen || !this.spendCount;
|
|
147
191
|
}
|
|
148
192
|
|
|
149
193
|
/** What the lines and the two marks mean. Shown until it is closed, and then not again unless asked for. */
|
|
@@ -196,7 +240,7 @@ export class Canvas {
|
|
|
196
240
|
this.root.addEventListener('pointerdown', (e) => {
|
|
197
241
|
if (e.button !== 0) return; // right/middle must not pan
|
|
198
242
|
const t = e.target;
|
|
199
|
-
if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return;
|
|
243
|
+
if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return; // the summary box is a .cv-legend too
|
|
200
244
|
panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
|
|
201
245
|
this.root.setPointerCapture(e.pointerId);
|
|
202
246
|
this.root.classList.add('cv-panning');
|
|
@@ -303,7 +347,7 @@ export class Canvas {
|
|
|
303
347
|
drawnAs: this.lastPlan?.density ?? null,
|
|
304
348
|
stacked: Boolean(this.lastPlan?.stacked),
|
|
305
349
|
drawn: this.drawn,
|
|
306
|
-
focus: this.focusId ? (this.nodes.get(this.focusId)?.agentType
|
|
350
|
+
focus: this.focusId ? (roleName(this.nodes.get(this.focusId)?.agentType) || this.focusId) : null,
|
|
307
351
|
groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
|
|
308
352
|
};
|
|
309
353
|
}
|
|
@@ -384,12 +428,14 @@ export class Canvas {
|
|
|
384
428
|
}
|
|
385
429
|
|
|
386
430
|
expandAll() {
|
|
387
|
-
|
|
431
|
+
// Beside itself an agent group opens one at a time, by its own card; "all" is then every other kind of group.
|
|
432
|
+
const mine = (it) => it.kind === 'group' && !(this.aside && it.type === 'type');
|
|
433
|
+
for (const it of this.lastPlan?.items ?? []) if (mine(it)) this.open.set(it.id, true);
|
|
388
434
|
// Opening one level can reveal groups inside it; open those too.
|
|
389
435
|
for (let i = 0; i < 4; i += 1) {
|
|
390
436
|
const p = this.#plan();
|
|
391
437
|
let more = false;
|
|
392
|
-
for (const it of p.items) if (it
|
|
438
|
+
for (const it of p.items) if (mine(it) && !it.open) { this.open.set(it.id, true); more = true; }
|
|
393
439
|
if (!more) break;
|
|
394
440
|
}
|
|
395
441
|
this.sessionY = null;
|
|
@@ -443,6 +489,7 @@ export class Canvas {
|
|
|
443
489
|
#plan() {
|
|
444
490
|
return plan(this.#visibleNodes(), {
|
|
445
491
|
group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
|
|
492
|
+
aside: this.aside,
|
|
446
493
|
});
|
|
447
494
|
}
|
|
448
495
|
|
|
@@ -569,7 +616,7 @@ export class Canvas {
|
|
|
569
616
|
|
|
570
617
|
#drawItem(it, liveCells) {
|
|
571
618
|
let el = this.els.get(it.id);
|
|
572
|
-
const shape = it.kind === 'group' ? `group:${it.
|
|
619
|
+
const shape = it.kind === 'group' ? `group:${it.boxed ? `open:${it.grid ? 'grid' : 'rows'}` : 'folded'}`
|
|
573
620
|
: `${it.kind}:${it.compact ? 'chip' : 'card'}`;
|
|
574
621
|
// A card that changes what it IS (an agent that becomes a chip, a group that opens) is rebuilt; one that only
|
|
575
622
|
// changes what it says is updated in place.
|
|
@@ -627,7 +674,7 @@ export class Canvas {
|
|
|
627
674
|
p.head.append(p.tile, p.name, mk('span', 'cv-fill'), p.sub, p.fold);
|
|
628
675
|
p.bar = mk('div', 'cv-bar');
|
|
629
676
|
p.count = mk('span', 'cv-sub');
|
|
630
|
-
if (it.
|
|
677
|
+
if (it.boxed) {
|
|
631
678
|
el.classList.add('cv-open');
|
|
632
679
|
el.append(p.head, p.bar);
|
|
633
680
|
} else {
|
|
@@ -701,7 +748,8 @@ export class Canvas {
|
|
|
701
748
|
#fillSession(el, it) {
|
|
702
749
|
const n = it.node;
|
|
703
750
|
const st = this.sessionState;
|
|
704
|
-
const
|
|
751
|
+
const bg = n.backgroundNow?.length ?? 0;
|
|
752
|
+
const sig = JSON.stringify([n.gitBranch, n.cwd, n.turns, n.tokens, st?.word, st?.tone, bg]);
|
|
705
753
|
if (el.sig === sig) return;
|
|
706
754
|
el.sig = sig;
|
|
707
755
|
const p = el.parts;
|
|
@@ -712,7 +760,10 @@ export class Canvas {
|
|
|
712
760
|
p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
|
|
713
761
|
const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
|
|
714
762
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
|
|
763
|
+
// What the session has running that is not an agent: commands sent to the background.
|
|
764
|
+
if (bg) bits.push(`${bg} in background`);
|
|
715
765
|
p.sub.textContent = bits.join(' · ');
|
|
766
|
+
p.sub.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
|
|
716
767
|
el.title = 'Click to inspect this session';
|
|
717
768
|
el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
|
|
718
769
|
}
|
|
@@ -720,7 +771,7 @@ export class Canvas {
|
|
|
720
771
|
#fillAgent(el, n, compact) {
|
|
721
772
|
const st = this.#statusOf(n);
|
|
722
773
|
const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
|
|
723
|
-
const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : (n
|
|
774
|
+
const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : shownType(n);
|
|
724
775
|
const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
|
|
725
776
|
const dim = this.dimmed(n);
|
|
726
777
|
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status]);
|
|
@@ -735,6 +786,9 @@ export class Canvas {
|
|
|
735
786
|
el.classList.toggle('cv-dim', dim);
|
|
736
787
|
p.name.textContent = type;
|
|
737
788
|
p.task.textContent = task;
|
|
789
|
+
// Both are cut to the card's width; hovering says them whole, and the type as it is declared.
|
|
790
|
+
p.name.title = n.kind === 'workflow' ? '' : (n.agentType ?? '');
|
|
791
|
+
p.task.title = task;
|
|
738
792
|
if (compact) {
|
|
739
793
|
p.dot.dataset.tone = st.tone ?? 'none';
|
|
740
794
|
p.word.textContent = st.word;
|
|
@@ -752,19 +806,26 @@ export class Canvas {
|
|
|
752
806
|
const p = el.parts;
|
|
753
807
|
const counts = it.counts;
|
|
754
808
|
const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
|
|
755
|
-
const sig = JSON.stringify([it.label, says, it.open, it.type, it.status]);
|
|
809
|
+
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status]);
|
|
756
810
|
if (el.sig !== sig) {
|
|
757
811
|
el.sig = sig;
|
|
758
812
|
el.dataset.state = stateOf(it.status);
|
|
759
813
|
el.dataset.status = it.status;
|
|
760
814
|
el.dataset.group = it.type;
|
|
761
815
|
el.dataset.open = String(Boolean(it.open));
|
|
816
|
+
// Open beside itself: the card stays a card and its agents are the next column.
|
|
817
|
+
el.dataset.aside = String(Boolean(it.open && !it.boxed));
|
|
762
818
|
p.name.textContent = it.label;
|
|
819
|
+
p.name.title = it.real ?? '';
|
|
763
820
|
if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
|
|
764
821
|
else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
|
|
765
|
-
|
|
766
|
-
p.
|
|
767
|
-
p.
|
|
822
|
+
// Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
|
|
823
|
+
p.sub.textContent = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
|
|
824
|
+
p.sub.classList.toggle('cv-times', it.type === 'type');
|
|
825
|
+
p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
|
|
826
|
+
// Read aloud, the badge is part of the name.
|
|
827
|
+
const said = it.type === 'type' ? `${it.label} × ${it.count}` : it.label;
|
|
828
|
+
p.fold.setAttribute('aria-label', `${it.open ? 'Fold' : 'Open'} ${said}`);
|
|
768
829
|
p.fold.setAttribute('aria-expanded', String(Boolean(it.open)));
|
|
769
830
|
// The bar: how the group went, in the order the eye wants it.
|
|
770
831
|
const order = ['done', 'running', 'starting', 'failed', 'killed', 'stopped', 'stale', 'ended'];
|
|
@@ -776,11 +837,11 @@ export class Canvas {
|
|
|
776
837
|
return seg;
|
|
777
838
|
}));
|
|
778
839
|
p.bar.title = says;
|
|
779
|
-
if (!it.
|
|
780
|
-
el.title = it.open ? '' : `Click to show ${it.members.length} agents
|
|
781
|
-
el.setAttribute('aria-label', `${
|
|
840
|
+
if (!it.boxed) p.count.textContent = says;
|
|
841
|
+
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
842
|
+
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
782
843
|
}
|
|
783
|
-
if (!it.
|
|
844
|
+
if (!it.boxed) return;
|
|
784
845
|
|
|
785
846
|
for (const cell of it.cells) {
|
|
786
847
|
liveCells.add(cell.id);
|
|
@@ -816,7 +877,7 @@ export class Canvas {
|
|
|
816
877
|
const st = this.#statusOf(n);
|
|
817
878
|
const who = it.grid ? this.#identity(n) : this.#fillTile(c.parts.tile, n);
|
|
818
879
|
// Inside a group of one type the type is the group's name, so a member is told apart by its task.
|
|
819
|
-
const text = it.type === 'type' ? (n.description || n
|
|
880
|
+
const text = it.type === 'type' ? (n.description || shownType(n) || n.id) : shownType(n);
|
|
820
881
|
const dim = this.dimmed(n);
|
|
821
882
|
const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id]);
|
|
822
883
|
if (c.sig === csig) continue;
|
|
@@ -826,9 +887,10 @@ export class Canvas {
|
|
|
826
887
|
c.classList.toggle('cv-dim', dim);
|
|
827
888
|
c.classList.toggle('cv-selected', this.selected === n.id);
|
|
828
889
|
c.parts.name.textContent = text;
|
|
890
|
+
c.title = hoverOf(n);
|
|
829
891
|
c.parts.dot.dataset.tone = st.tone ?? 'none';
|
|
830
892
|
c.parts.word.textContent = st.word;
|
|
831
|
-
c.setAttribute('aria-label', `${n
|
|
893
|
+
c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
|
|
832
894
|
}
|
|
833
895
|
}
|
|
834
896
|
|
|
@@ -986,11 +1048,23 @@ export class Canvas {
|
|
|
986
1048
|
const it = this.lastPlan?.items.find((x) => x.id === groupId);
|
|
987
1049
|
if (!it || it.kind !== 'group') return;
|
|
988
1050
|
this.open.set(groupId, !it.open);
|
|
1051
|
+
// Beside itself, one agent's tasks at a time: a second column of them from two groups reads as one list.
|
|
1052
|
+
if (!it.open && it.type === 'type' && this.aside) {
|
|
1053
|
+
for (const other of this.lastPlan.items) if (other.kind === 'group' && other.type === 'type' && other.id !== groupId) this.open.set(other.id, false);
|
|
1054
|
+
}
|
|
989
1055
|
this.sessionY = null;
|
|
990
1056
|
this.#persist();
|
|
991
1057
|
this.#redraw();
|
|
992
1058
|
}
|
|
993
1059
|
|
|
1060
|
+
/** Whether an agent group opens into the next column (a window with room) or downward (a narrow one). */
|
|
1061
|
+
setAside(on) {
|
|
1062
|
+
if (this.aside === Boolean(on)) return;
|
|
1063
|
+
this.aside = Boolean(on);
|
|
1064
|
+
this.sessionY = null;
|
|
1065
|
+
if (this.lastPlan) this.#redraw({ fit: true });
|
|
1066
|
+
}
|
|
1067
|
+
|
|
994
1068
|
/** Drop the selection without pretending a card was clicked. */
|
|
995
1069
|
clearSelection() {
|
|
996
1070
|
this.selected = null;
|
|
@@ -1141,7 +1215,8 @@ export class Canvas {
|
|
|
1141
1215
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} tokens`);
|
|
1142
1216
|
if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
|
|
1143
1217
|
this.tipEl.replaceChildren(
|
|
1144
|
-
mk('strong', null, n
|
|
1218
|
+
mk('strong', null, shownType(n)),
|
|
1219
|
+
mk('div', 'cv-sub cv-real', n.agentType ?? ''),
|
|
1145
1220
|
mk('div', null, n.description || 'No task recorded'),
|
|
1146
1221
|
mk('div', 'cv-sub', bits.length ? bits.join(' · ') : 'No numbers yet'),
|
|
1147
1222
|
);
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -375,6 +375,7 @@ class Pane {
|
|
|
375
375
|
node.sig = sig;
|
|
376
376
|
const text = el('span', 'deleg-text');
|
|
377
377
|
text.append(el('span', 'deleg-type', card.type), el('span', 'deleg-task', card.task));
|
|
378
|
+
text.title = [card.real, card.task].filter(Boolean).join('\n');
|
|
378
379
|
const parts = [];
|
|
379
380
|
const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
|
|
380
381
|
if (tile) parts.push(tile);
|