crewforth 3.0.3 → 3.1.0-rc.1

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/routing-eval.sh +7 -3
  16. package/kit/eval/smoke-test.sh +2545 -214
  17. package/kit/hooks/commit-msg +1 -1
  18. package/kit/hooks/context-usage.sh +7 -1
  19. package/kit/hooks/guard-bash.sh +1608 -103
  20. package/kit/hooks/guard-commit-scan.sh +138 -19
  21. package/kit/hooks/guard-powershell.sh +89 -15
  22. package/kit/hooks/guard-write.sh +98 -22
  23. package/kit/hooks/no-bash-guard.ps1 +71 -0
  24. package/kit/hooks/pre-commit +5 -2
  25. package/kit/hooks/prompt-approval.sh +484 -0
  26. package/kit/hooks/route-hint.sh +13 -5
  27. package/kit/hooks/session-rehydrate.sh +18 -0
  28. package/kit/hooks/skill-trust.sh +17 -2
  29. package/kit/owned-blobs.tsv +43 -0
  30. package/kit/settings.json +20 -4
  31. package/kit/skills/a11y/SKILL.md +1 -0
  32. package/kit/skills/crew-doctor/SKILL.md +2 -2
  33. package/kit/skills/frontend/SKILL.md +3 -3
  34. package/kit/skills/frontend-flutter/SKILL.md +94 -0
  35. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
  36. package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
  37. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -0,0 +1,457 @@
1
+ // Where everything on the graph goes.
2
+ //
3
+ // A session's agents are laid out as a tree that reads left to right: the session on the left, what it spawned in
4
+ // the next column, what those spawned in the one after. Siblings stack downward. This file decides which nodes are
5
+ // drawn on their own, which are drawn as one group, how big each thing is and where it sits. It touches no page:
6
+ // it is a function from the server's nodes to boxes and wires, so every rule below can be asserted by calling it.
7
+ //
8
+ // THREE RULES THE REST FOLLOWS FROM
9
+ //
10
+ // 1. Arrival appends. Every node gets a sequence number the first time it is seen, and a column is stacked in
11
+ // that order. A new agent therefore lands at the end of its column and nothing that was already drawn changes
12
+ // place. What does move things is a container growing (a run that gains a member is taller) and the viewer
13
+ // asking for a different picture (folding, grouping, density).
14
+ // 2. Nothing here reads the zoom. Positions are a function of the nodes and the viewer's choices, never of the
15
+ // view, so zooming cannot move a card.
16
+ // 3. Colour is not decided here at all. A box carries its status; the stylesheet turns status into colour.
17
+
18
+ /* ------------------------------------------------------------- measures --- */
19
+
20
+ export const SIZE = {
21
+ padX: 24, padY: 16, // the canvas's own margin
22
+ colGap: 56, // between one depth and the next
23
+ rowGap: 12, // between siblings
24
+ session: { w: 200, h: 84 },
25
+ card: { w: 248, h: 64 }, // an agent, and a folded group
26
+ chip: { w: 248, h: 36 }, // an agent at compact density
27
+ group: { // an open group: a container holding its members
28
+ padX: 8, top: 10, head: 20, gap: 6, bar: 4, bottom: 8,
29
+ row: { h: 40, gap: 6 }, // a member as a row
30
+ cell: { w: 150, h: 36, gap: 8 }, // a member as a chip in a grid
31
+ rowsMax: 8, // more members than this and rows become a grid
32
+ colsMax: 4,
33
+ },
34
+ };
35
+
36
+ // What `Auto` density does, and when. It reaches for things in order — nothing, then folding agents of one type
37
+ // together, then chips, then folding the largest groups — and stops at the first that makes the picture short
38
+ // enough to read. Both numbers were chosen by measuring; the measurements are in the pull request that added
39
+ // this file.
40
+ export const AUTO = {
41
+ // How tall the picture may be. The canvas of a 1440x900 window with the inspector open is 724px high, of which
42
+ // fitting leaves 676 after its margin; a card's 12px name reads down to 9px, which is a zoom of 0.75.
43
+ // 676 / 0.75 = 901.
44
+ tallest: 900,
45
+ // Same-type siblings fold into one card from this many, once the picture is too tall.
46
+ stackAt: 3,
47
+ };
48
+
49
+ export const GROUPINGS = ['run', 'type', 'parent', 'none'];
50
+ export const DENSITIES = ['auto', 'comfortable', 'compact'];
51
+
52
+ // The handful of things a status means to a reader. `killed` and `stopped` come off the transcript verbatim and
53
+ // mean what `failed` means: this branch did not finish on its own terms.
54
+ export const STATE = {
55
+ running: 'live',
56
+ starting: 'waking',
57
+ done: 'done',
58
+ failed: 'failed',
59
+ killed: 'failed',
60
+ stopped: 'failed',
61
+ ended: 'quiet',
62
+ stale: 'quiet',
63
+ session: 'root',
64
+ };
65
+ export const stateOf = (status) => STATE[status] ?? 'unknown';
66
+
67
+ const SESSION = 'session';
68
+
69
+ /* --------------------------------------------------------------- groups --- */
70
+
71
+ const typeOf = (n) => n.agentType ?? 'unknown agent';
72
+ const byArrival = (seq) => (a, b) => (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0);
73
+
74
+ /** Counts by status, in the order a bar draws them. */
75
+ export function tally(members) {
76
+ const counts = {};
77
+ for (const m of members) counts[m.status ?? 'unknown'] = (counts[m.status ?? 'unknown'] ?? 0) + 1;
78
+ return counts;
79
+ }
80
+
81
+ /**
82
+ * The one status a group's wire and frame speak for. Something alive outranks something wrong, and something wrong
83
+ * outranks something finished: a group with a running member is still working, and that is what its wire shows,
84
+ * while its bar and its count carry the failure.
85
+ */
86
+ export function groupStatus(members) {
87
+ const states = new Set(members.map((m) => stateOf(m.status)));
88
+ if (states.has('live')) return 'running';
89
+ if (states.has('waking')) return 'starting';
90
+ if (states.has('failed')) return 'failed';
91
+ if (states.has('done')) return 'done';
92
+ if (states.has('quiet')) return 'ended';
93
+ return members[0]?.status ?? 'unknown';
94
+ }
95
+
96
+ /**
97
+ * Give every node not seen before the next sequence number, in an order that makes a first layout tidy: a parent's
98
+ * children together, each family in the order it started. Later arrivals simply continue the count.
99
+ */
100
+ export function sequence(nodes, seq = new Map()) {
101
+ const kids = new Map();
102
+ for (const n of nodes) {
103
+ if (n.kind === 'session') continue;
104
+ const p = n.parentId ?? SESSION;
105
+ if (!kids.has(p)) kids.set(p, []);
106
+ kids.get(p).push(n);
107
+ }
108
+ let next = seq.size ? Math.max(...seq.values()) + 1 : 0;
109
+ const visit = (id) => {
110
+ const list = (kids.get(id) ?? []).slice().sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity)
111
+ || String(a.id).localeCompare(String(b.id)));
112
+ for (const n of list) {
113
+ if (!seq.has(n.id)) { seq.set(n.id, next); next += 1; }
114
+ visit(n.id);
115
+ }
116
+ };
117
+ const session = nodes.find((n) => n.kind === 'session');
118
+ if (session && !seq.has(session.id)) { seq.set(session.id, -1); }
119
+ visit(session?.id ?? SESSION);
120
+ // A node whose parent is not in the graph still gets a place.
121
+ for (const n of nodes) if (!seq.has(n.id)) { seq.set(n.id, next); next += 1; }
122
+ return seq;
123
+ }
124
+
125
+ /**
126
+ * Which nodes are drawn together. Returns the items of the tree: each is a session, an agent, or a group.
127
+ *
128
+ * `run` — a workflow run is a group holding its agents.
129
+ * `type` — the same, and siblings of one agent type are a group.
130
+ * `parent` — the same, and an agent's children are a group.
131
+ * `none` — nothing is grouped; a run is an ordinary card with its agents after it.
132
+ *
133
+ * At `auto` density, once the picture is too tall (`stack`), siblings of one type fold into a group from
134
+ * AUTO.stackAt whatever the grouping is, except under `none`, which means none.
135
+ */
136
+ function itemsOf(nodes, { group, density, seq, stack }) {
137
+ const byId = new Map(nodes.map((n) => [n.id, n]));
138
+ const kids = new Map();
139
+ for (const n of nodes) {
140
+ if (n.kind === 'session') continue;
141
+ const p = byId.has(n.parentId) ? n.parentId : SESSION;
142
+ if (!kids.has(p)) kids.set(p, []);
143
+ kids.get(p).push(n);
144
+ }
145
+ for (const list of kids.values()) list.sort(byArrival(seq));
146
+
147
+ const items = [];
148
+ const session = nodes.find((n) => n.kind === 'session');
149
+ if (session) items.push({ id: session.id, kind: 'session', node: session, parent: null, depth: 0 });
150
+
151
+ const stackAt = group === 'type' ? 2 : (density === 'auto' && group !== 'none' && stack ? AUTO.stackAt : Infinity);
152
+
153
+ // `anchor` is the item a node's wire comes out of; `via` is the member row inside it, when the parent is one.
154
+ const place = (parentId, anchor, via, depth) => {
155
+ const list = kids.get(parentId) ?? [];
156
+ if (!list.length) return;
157
+
158
+ // An agent's children as one group.
159
+ if (group === 'parent' && list.length >= 2 && byId.get(parentId)?.kind === 'agent') {
160
+ const g = groupItem(`kids:${parentId}`, 'parent', list, anchor, via, depth, seq, {
161
+ label: `${typeOf(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
162
+ });
163
+ items.push(g);
164
+ for (const m of list) place(m.id, g, m.id, depth + 1);
165
+ return;
166
+ }
167
+
168
+ const types = new Map();
169
+ for (const n of list) {
170
+ if (n.kind !== 'agent') continue;
171
+ const t = typeOf(n);
172
+ if (!types.has(t)) types.set(t, []);
173
+ types.get(t).push(n);
174
+ }
175
+ const stacked = new Set();
176
+ for (const [t, members] of types) {
177
+ if (members.length < stackAt) continue;
178
+ const g = groupItem(`type:${parentId}:${t}`, 'type', members, anchor, via, depth, seq, {
179
+ label: `${t} × ${members.length}`, agentType: members[0].agentType ?? null,
180
+ });
181
+ items.push(g);
182
+ for (const m of members) { stacked.add(m.id); place(m.id, g, m.id, depth + 1); }
183
+ }
184
+
185
+ for (const n of list) {
186
+ if (stacked.has(n.id)) continue;
187
+ if (n.kind === 'workflow' && group !== 'none') {
188
+ const members = kids.get(n.id) ?? [];
189
+ const g = groupItem(n.id, 'run', members, anchor, via, depth, seq, {
190
+ label: n.workflowId ?? 'workflow run', node: n,
191
+ });
192
+ g.seq = seq.get(n.id) ?? g.seq;
193
+ items.push(g);
194
+ for (const m of members) place(m.id, g, m.id, depth + 1);
195
+ continue;
196
+ }
197
+ const item = { id: n.id, kind: n.kind === 'workflow' ? 'run-card' : 'agent', node: n, parent: anchor, via, depth, seq: seq.get(n.id) ?? 0 };
198
+ items.push(item);
199
+ place(n.id, item, null, depth + 1);
200
+ }
201
+ };
202
+ if (session) place(session.id, items[0], null, 1);
203
+ return items;
204
+ }
205
+
206
+ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
207
+ return {
208
+ id,
209
+ kind: 'group',
210
+ type,
211
+ members,
212
+ counts: tally(members),
213
+ status: groupStatus(members),
214
+ parent: anchor,
215
+ via,
216
+ depth,
217
+ seq: Math.min(...members.map((m) => seq.get(m.id) ?? 0), Infinity),
218
+ ...extra,
219
+ };
220
+ }
221
+
222
+ /* ----------------------------------------------------------------- plan --- */
223
+
224
+ /**
225
+ * @param nodes the server's nodes
226
+ * @param opts.group one of GROUPINGS
227
+ * @param opts.density one of DENSITIES
228
+ * @param opts.seq Map id -> arrival number, kept by the caller across calls (see `sequence`)
229
+ * @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
230
+ * @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
231
+ * @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
232
+ * @returns { items, edges, width, height, drawn, density, sessionY }
233
+ */
234
+ export function plan(nodes, opts = {}) {
235
+ const group = GROUPINGS.includes(opts.group) ? opts.group : 'run';
236
+ const asked = DENSITIES.includes(opts.density) ? opts.density : 'auto';
237
+ const seq = opts.seq ?? sequence(nodes);
238
+ const open = opts.open ?? new Map();
239
+ const pinned = opts.pinned ?? new Map();
240
+
241
+ // A type group starts folded: it is the same thing several times. A run or an agent's children start open.
242
+ const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : g.type !== 'type');
243
+
244
+ // What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
245
+ const drawFrom = (items) => {
246
+ // A node inside a folded group is not drawn, and neither is anything it spawned.
247
+ const hidden = new Set();
248
+ for (const it of items) {
249
+ const p = it.parent;
250
+ if (!p) continue;
251
+ if (hidden.has(p.id) || (p.kind === 'group' && it.via && !isOpen(p))) hidden.add(it.id);
252
+ }
253
+ const shown = items.filter((it) => !hidden.has(it.id));
254
+ let count = 0;
255
+ for (const it of shown) {
256
+ if (it.kind === 'agent' || it.kind === 'run-card') count += 1;
257
+ else if (it.kind === 'group') count += isOpen(it) ? it.members.length : 1;
258
+ }
259
+ return { shown, count };
260
+ };
261
+
262
+ const G = SIZE.group;
263
+
264
+ /** Size and place everything drawn, at one density. Returns the box the picture needs. */
265
+ const lay = (drawnItems, compact) => {
266
+ for (const it of drawnItems) {
267
+ if (it.kind === 'session') { it.w = SIZE.session.w; it.h = SIZE.session.h; continue; }
268
+ if (it.kind !== 'group') {
269
+ const sz = compact ? SIZE.chip : SIZE.card;
270
+ it.w = sz.w; it.h = sz.h; it.compact = compact;
271
+ continue;
272
+ }
273
+ it.open = isOpen(it);
274
+ if (!it.open) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
275
+ const n = it.members.length;
276
+ const grid = compact || n > G.rowsMax;
277
+ const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
278
+ const rows = Math.ceil(n / cols);
279
+ const cellH = grid ? G.cell.h : G.row.h;
280
+ const cellGap = grid ? G.cell.gap : G.row.gap;
281
+ const bodyH = rows * cellH + Math.max(0, rows - 1) * cellGap;
282
+ it.grid = grid;
283
+ it.cols = cols;
284
+ it.w = cols === 1 ? SIZE.card.w : 2 * G.padX + cols * G.cell.w + (cols - 1) * G.cell.gap;
285
+ it.h = G.top + G.head + G.gap + G.bar + G.gap + bodyH + G.bottom;
286
+ // Where each member sits inside the container, so a wire can leave from its row.
287
+ const cellW = (it.w - 2 * G.padX - (cols - 1) * cellGap) / cols;
288
+ const top = G.top + G.head + G.gap + G.bar + G.gap;
289
+ it.cells = it.members.map((m, i) => ({
290
+ id: m.id,
291
+ node: m,
292
+ x: G.padX + (i % cols) * (cellW + cellGap),
293
+ y: top + Math.floor(i / cols) * (cellH + cellGap),
294
+ w: cellW,
295
+ h: cellH,
296
+ }));
297
+ }
298
+
299
+ // Columns: one per depth, as wide as the widest thing in it.
300
+ const depths = [...new Set(drawnItems.map((it) => it.depth))].sort((a, b) => a - b);
301
+ const colX = new Map();
302
+ let x = SIZE.padX;
303
+ for (const d of depths) {
304
+ colX.set(d, x);
305
+ x += Math.max(...drawnItems.filter((it) => it.depth === d).map((it) => it.w)) + SIZE.colGap;
306
+ }
307
+ const width = x - SIZE.colGap + SIZE.padX;
308
+
309
+ // Stack each column in arrival order. A child starts level with what spawned it when there is room, and
310
+ // below the previous box when there is not.
311
+ const anchorY = (it) => {
312
+ const p = it.parent;
313
+ if (!p || p.kind === 'session') return SIZE.padY;
314
+ const cell = cellOf(it);
315
+ const cy = cell ? p.y + cell.y + cell.h / 2 : p.y + p.h / 2;
316
+ return Math.max(SIZE.padY, cy - it.h / 2);
317
+ };
318
+ let height = 0;
319
+ for (const d of depths) {
320
+ if (d === 0) continue;
321
+ const col = drawnItems.filter((it) => it.depth === d).sort((a, b) => a.seq - b.seq || String(a.id).localeCompare(String(b.id)));
322
+ let cursor = SIZE.padY;
323
+ for (const it of col) {
324
+ it.x = colX.get(d);
325
+ it.y = Math.max(cursor, anchorY(it));
326
+ cursor = it.y + it.h + SIZE.rowGap;
327
+ }
328
+ height = Math.max(height, cursor - SIZE.rowGap + SIZE.padY);
329
+ }
330
+
331
+ // The session sits beside the middle of what it spawned, and stays where it was first put.
332
+ const session = drawnItems.find((it) => it.kind === 'session');
333
+ let sessionY = opts.sessionY;
334
+ if (session) {
335
+ const first = drawnItems.filter((it) => it.depth === 1);
336
+ if (sessionY == null) {
337
+ const top = first.length ? Math.min(...first.map((it) => it.y)) : SIZE.padY;
338
+ const bottom = first.length ? Math.max(...first.map((it) => it.y + it.h)) : SIZE.padY + session.h;
339
+ sessionY = Math.max(SIZE.padY, Math.round((top + bottom) / 2 - session.h / 2));
340
+ }
341
+ session.x = colX.get(0);
342
+ session.y = sessionY;
343
+ height = Math.max(height, sessionY + session.h + SIZE.padY);
344
+ }
345
+ return { width, height, sessionY: sessionY ?? null };
346
+ };
347
+ const cellOf = (it) => (it.via && it.parent?.kind === 'group' && it.parent.open
348
+ ? it.parent.cells?.find((c) => c.id === it.via) ?? null : null);
349
+
350
+ // Auto, in the order it reaches for things: nothing while the picture is short enough to read; siblings of one
351
+ // 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 }));
353
+ let compact = asked === 'compact';
354
+ let stacked = false;
355
+ let box = lay(pass.shown, compact);
356
+ if (asked === 'auto' && box.height > AUTO.tallest) {
357
+ if (group !== 'none' && group !== 'type') {
358
+ const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true }));
359
+ if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
360
+ }
361
+ if (box.height > AUTO.tallest) { compact = true; box = lay(pass.shown, true); }
362
+ }
363
+ const drawnItems = pass.shown;
364
+ const drawn = pass.count;
365
+ const density = compact ? 'compact' : 'comfortable';
366
+ const { width } = box;
367
+ let { height } = box;
368
+ const { sessionY } = box;
369
+
370
+ // The viewer's own placements win, and the wires follow them.
371
+ for (const it of drawnItems) {
372
+ const p = pinned.get(it.id);
373
+ if (p) { it.x = p.x; it.y = p.y; it.pinned = true; }
374
+ }
375
+
376
+ // Wires: out of the right edge of what spawned a thing, into its left edge.
377
+ const edges = [];
378
+ for (const it of drawnItems) {
379
+ const p = it.parent;
380
+ if (!p) continue;
381
+ const cell = cellOf(it);
382
+ const from = cell
383
+ ? { x: p.x + cell.x + cell.w, y: p.y + cell.y + cell.h / 2 }
384
+ : { x: p.x + p.w, y: p.y + p.h / 2 };
385
+ const status = it.kind === 'group' ? it.status : (it.node.status ?? 'unknown');
386
+ // A group's wire says whether the group is still working. What went wrong inside it is said by its bar, its
387
+ // count and the member itself; a red wire into twelve agents because one failed would say the wrong thing
388
+ // about eleven.
389
+ const state = it.kind === 'group' && stateOf(status) === 'failed' ? 'done' : stateOf(status);
390
+ edges.push({
391
+ key: `${it.via ?? p.id}\u0000${it.id}`,
392
+ source: it.via ?? p.id,
393
+ target: it.id,
394
+ from,
395
+ to: { x: it.x, y: it.y + it.h / 2 },
396
+ status,
397
+ state,
398
+ });
399
+ }
400
+
401
+ return {
402
+ 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,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Which open groups to fold when a session first opens too tall even as chips: the largest first, until the
409
+ * picture is short enough to read or there is nothing left to fold. Returns the ids to fold; an empty list when
410
+ * nothing needs it.
411
+ */
412
+ export function crowdFolds(nodes, opts = {}) {
413
+ if ((opts.density ?? 'auto') !== 'auto') return [];
414
+ const open = new Map(opts.open ?? []);
415
+ const folds = [];
416
+ for (let guard = 0; guard < 64; guard += 1) {
417
+ const p = plan(nodes, { ...opts, open, pinned: new Map(), sessionY: null });
418
+ if (p.height <= AUTO.tallest) break;
419
+ const next = p.items.filter((it) => it.kind === 'group' && it.open && it.members.length > 1)
420
+ .sort((a, b) => b.members.length - a.members.length || String(a.id).localeCompare(String(b.id)))[0];
421
+ if (!next) break;
422
+ open.set(next.id, false);
423
+ folds.push(next.id);
424
+ }
425
+ return folds;
426
+ }
427
+
428
+ /** The zoom that shows the whole plan in a pane, between the two bounds the toolbar allows. */
429
+ export const ZOOM = { min: 0.25, max: 2, fitMax: 1, pad: 24 };
430
+ export function fitZoom(width, height, paneW, paneH) {
431
+ const k = Math.min((paneW - 2 * ZOOM.pad) / Math.max(1, width), (paneH - 2 * ZOOM.pad) / Math.max(1, height), ZOOM.fitMax);
432
+ return Math.min(ZOOM.max, Math.max(ZOOM.min, k));
433
+ }
434
+
435
+ /* ------------------------------------------------------------ attention --- */
436
+
437
+ /**
438
+ * What needs someone: agents waiting on the viewer first, then the ones that did not finish on their own terms,
439
+ * newest first. `waiting` is the set of agent ids the page knows are parked on an approval; the graph itself has
440
+ * no such status.
441
+ */
442
+ export function attention(nodes, waiting = new Set()) {
443
+ const agents = nodes.filter((n) => n.kind === 'agent');
444
+ const when = (n) => n.endedAt ?? n.updatedAt ?? n.startedAt ?? 0;
445
+ const needs = agents.filter((n) => waiting.has(n.id)).sort((a, b) => when(b) - when(a));
446
+ const failed = agents.filter((n) => !waiting.has(n.id) && stateOf(n.status) === 'failed').sort((a, b) => when(b) - when(a));
447
+ return [
448
+ ...needs.map((n) => ({ id: n.id, tone: 'waiting', name: typeOf(n), says: 'waiting for you' })),
449
+ ...failed.map((n) => ({
450
+ id: n.id,
451
+ tone: 'fail',
452
+ name: typeOf(n),
453
+ says: [n.status, n.errors ? `${n.errors} ${n.errors === 1 ? 'error' : 'errors'}` : null,
454
+ n.workflow ? `in ${n.workflow}` : null].filter(Boolean).join(' · '),
455
+ })),
456
+ ];
457
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="8" cy="8" r="5.2"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>