crewforth 0.0.1 → 3.0.0-rc.2

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 (169) hide show
  1. package/CHANGELOG.md +3805 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1739 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +639 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/skill-listing.awk +43 -0
  29. package/kit/eval/lib/star.sh +70 -0
  30. package/kit/eval/parser-conformance.sh +612 -0
  31. package/kit/eval/preflight.sh +186 -0
  32. package/kit/eval/routing-eval.sh +209 -0
  33. package/kit/eval/scan-skill.sh +162 -0
  34. package/kit/eval/smoke-test.sh +6736 -0
  35. package/kit/eval/update-guard.sh +81 -0
  36. package/kit/eval/utilization.sh +130 -0
  37. package/kit/hooks/board-sync.sh +106 -0
  38. package/kit/hooks/board.sh +962 -0
  39. package/kit/hooks/commit-msg +49 -0
  40. package/kit/hooks/context-usage.sh +353 -0
  41. package/kit/hooks/floor-blocklist.txt +124 -0
  42. package/kit/hooks/guard-bash.sh +1433 -0
  43. package/kit/hooks/guard-commit-scan.sh +511 -0
  44. package/kit/hooks/guard-write.sh +513 -0
  45. package/kit/hooks/pre-commit +577 -0
  46. package/kit/hooks/route-hint.sh +185 -0
  47. package/kit/hooks/secret-blocklist.txt +82 -0
  48. package/kit/hooks/session-guard.sh +149 -0
  49. package/kit/hooks/session-rehydrate.sh +41 -0
  50. package/kit/hooks/session-stats.sh +252 -0
  51. package/kit/hooks/session-update-check.sh +236 -0
  52. package/kit/hooks/skill-trust.sh +163 -0
  53. package/kit/hooks/trace-blocklist.txt +53 -0
  54. package/kit/settings.json +132 -0
  55. package/kit/skills/a11y/SKILL.md +44 -0
  56. package/kit/skills/adr/SKILL.md +74 -0
  57. package/kit/skills/api-design/SKILL.md +50 -0
  58. package/kit/skills/automode-policy/SKILL.md +91 -0
  59. package/kit/skills/automode-policy/references/policy.json +13 -0
  60. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  61. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  62. package/kit/skills/backend-architecture/SKILL.md +103 -0
  63. package/kit/skills/brainstorm/SKILL.md +47 -0
  64. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  65. package/kit/skills/commit-message/SKILL.md +82 -0
  66. package/kit/skills/commit-message/references/examples.md +8 -0
  67. package/kit/skills/confidence-check/SKILL.md +86 -0
  68. package/kit/skills/crew-board/SKILL.md +55 -0
  69. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  70. package/kit/skills/crew-code-review/SKILL.md +151 -0
  71. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  72. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  73. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  74. package/kit/skills/crew-doctor/SKILL.md +46 -0
  75. package/kit/skills/crew-gates/SKILL.md +25 -0
  76. package/kit/skills/crew-handoff/SKILL.md +14 -0
  77. package/kit/skills/crew-plan/SKILL.md +18 -0
  78. package/kit/skills/crew-review/SKILL.md +26 -0
  79. package/kit/skills/crew-ship/SKILL.md +19 -0
  80. package/kit/skills/crew-skill/SKILL.md +62 -0
  81. package/kit/skills/crew-studio/SKILL.md +129 -0
  82. package/kit/skills/crew-update/SKILL.md +43 -0
  83. package/kit/skills/db-migration/SKILL.md +120 -0
  84. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  85. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  86. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  87. package/kit/skills/dependency-audit/SKILL.md +61 -0
  88. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  89. package/kit/skills/deploy/SKILL.md +100 -0
  90. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  91. package/kit/skills/deploy/references/rollback.md +18 -0
  92. package/kit/skills/deploy/references/scripts.md +9 -0
  93. package/kit/skills/deploy/references/topology-a.md +128 -0
  94. package/kit/skills/deploy/references/topology-b.md +44 -0
  95. package/kit/skills/docs-writer/SKILL.md +53 -0
  96. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  97. package/kit/skills/eval-grader/SKILL.md +75 -0
  98. package/kit/skills/eval-grader/references/method.md +51 -0
  99. package/kit/skills/frontend/SKILL.md +56 -0
  100. package/kit/skills/frontend/references/verify-contract.md +41 -0
  101. package/kit/skills/frontend-design/SKILL.md +71 -0
  102. package/kit/skills/frontend-design/references/design-review.md +34 -0
  103. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  104. package/kit/skills/handoff/SKILL.md +71 -0
  105. package/kit/skills/handoff/references/cold-reader.md +51 -0
  106. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  107. package/kit/skills/incident-runbook/SKILL.md +40 -0
  108. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  109. package/kit/skills/iterate/SKILL.md +40 -0
  110. package/kit/skills/mcp-builder/SKILL.md +54 -0
  111. package/kit/skills/mcp-builder/references/building.md +71 -0
  112. package/kit/skills/observability/SKILL.md +50 -0
  113. package/kit/skills/performance/SKILL.md +38 -0
  114. package/kit/skills/performance/references/bottlenecks.md +10 -0
  115. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  116. package/kit/skills/red-team/SKILL.md +48 -0
  117. package/kit/skills/reflect/SKILL.md +57 -0
  118. package/kit/skills/release/SKILL.md +44 -0
  119. package/kit/skills/security-scan/SKILL.md +99 -0
  120. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  121. package/kit/skills/security-scan/references/fronts.md +99 -0
  122. package/kit/skills/security-scan/references/prompting.md +23 -0
  123. package/kit/skills/security-scan/references/reporting.md +75 -0
  124. package/kit/skills/security-scan/references/verify.md +131 -0
  125. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  126. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  127. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  128. package/kit/skills/spec-planning/SKILL.md +92 -0
  129. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  130. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  131. package/kit/skills/teamboard/SKILL.md +88 -0
  132. package/kit/skills/teamboard/references/setup.md +37 -0
  133. package/kit/skills/testing/SKILL.md +49 -0
  134. package/kit/skills/testing/references/flaky-triage.md +51 -0
  135. package/kit/skills/threat-model/SKILL.md +57 -0
  136. package/kit/skills/threat-model/references/interview.md +51 -0
  137. package/kit/skills/threat-model/references/schema.md +42 -0
  138. package/kit/skills/token-budget/SKILL.md +51 -0
  139. package/kit/skills/trace-scan/SKILL.md +43 -0
  140. package/kit/skills/worktree/SKILL.md +40 -0
  141. package/kit/studio/README.md +266 -0
  142. package/kit/studio/ensure-node.sh +471 -0
  143. package/kit/studio/package.json +14 -0
  144. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  145. package/kit/studio/server/index.js +902 -0
  146. package/kit/studio/server/lib/crew-env.js +11 -0
  147. package/kit/studio/server/lib/fleet.js +146 -0
  148. package/kit/studio/server/lib/graph.js +533 -0
  149. package/kit/studio/server/lib/instance.js +135 -0
  150. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  151. package/kit/studio/server/lib/kit.js +158 -0
  152. package/kit/studio/server/lib/palette.js +118 -0
  153. package/kit/studio/server/lib/peers.js +72 -0
  154. package/kit/studio/server/lib/permissions.js +150 -0
  155. package/kit/studio/server/lib/projects.js +294 -0
  156. package/kit/studio/server/lib/roster.js +191 -0
  157. package/kit/studio/server/lib/session.js +309 -0
  158. package/kit/studio/server/lib/terminal.js +91 -0
  159. package/kit/studio/server/lib/transcript.js +100 -0
  160. package/kit/studio/web/app.js +1098 -0
  161. package/kit/studio/web/canvas.js +1179 -0
  162. package/kit/studio/web/chat.js +543 -0
  163. package/kit/studio/web/index.html +86 -0
  164. package/kit/studio/web/md.js +110 -0
  165. package/kit/studio/web/storage-migrate.js +23 -0
  166. package/kit/studio/web/style.css +1453 -0
  167. package/package.json +46 -8
  168. package/start.sh +764 -0
  169. package/bin/crewforth.js +0 -10
@@ -0,0 +1,1179 @@
1
+ // The orchestration canvas.
2
+ //
3
+ // HTML nodes over an SVG edge layer — the same split n8n and React Flow use,
4
+ // and for the same reason: cards are far easier to style as DOM, curves are far
5
+ // easier to draw as paths. One shared transform keeps them registered.
6
+ //
7
+ // Layout is automatic until you touch it. The moment a node is dragged its
8
+ // position is yours and is remembered per session; new agents still land on the
9
+ // auto grid around whatever you have arranged.
10
+
11
+ // Vertical flow: the session on top, everything it spawned beneath it. Depth
12
+ // reads as distance down the page, which is how delegation actually feels —
13
+ // work handed downward rather than sideways.
14
+ const NODE_W = 236;
15
+ const NODE_H = 104;
16
+ const SIBLING_GAP = 26; // between nodes of the same kind
17
+ const GROUP_GAP = 64; // between one kind and the next
18
+ const DEPTH_GAP = 128; // between one depth and the next
19
+ const MAX_PER_GROUP_ROW = 4;
20
+
21
+ // Kinds that are not agents still need a colour, and the card and the edge into
22
+ // it have to agree — a branch you can trace by colour stops working the moment
23
+ // the two are computed in different places. This is that one place.
24
+ const KIND_COLOR = { session: '#5b8cff', workflow: '#a874f5' };
25
+
26
+ // Raw status strings, folded into the handful of things motion has to say.
27
+ // `killed` and `stopped` come straight off the transcript and mean the same
28
+ // thing to a reader as `failed`: this branch did not finish on its own terms.
29
+ const FLOW_STATE = {
30
+ running: 'live',
31
+ starting: 'waking',
32
+ done: 'done',
33
+ failed: 'failed',
34
+ killed: 'failed',
35
+ stopped: 'failed',
36
+ ended: 'quiet',
37
+ stale: 'quiet',
38
+ session: 'root',
39
+ };
40
+
41
+ // How long an arriving edge takes to draw itself toward its new node. Short on
42
+ // purpose: this is a status panel, and anything a viewer has to wait through
43
+ // is a cost they pay on every spawn.
44
+ const DRAW_MS = 300;
45
+
46
+ // Above this many edges in motion at once the canvas stops moving them and
47
+ // shows the same states standing still.
48
+ //
49
+ // Two reasons, and the second is the one that decided the number. A dash
50
+ // travelling along a stroke is a paint-driven animation, not a composited one,
51
+ // so its cost scales with how many strokes are in motion rather than with how
52
+ // many exist. And 200 flowing lines carry less than 20 do — past a certain
53
+ // density motion stops reading as direction and starts reading as noise, which
54
+ // is the same reason the pulse is restrained rather than loud.
55
+ //
56
+ // Profiled 2026-09-20, and it says this ceiling is the AESTHETIC one, not a
57
+ // frame-budget one: 250 nodes with the budget full (60 strokes flowing) held a
58
+ // median frame of 8.3 ms with nothing over 16.7 ms, and forcing the state past
59
+ // the cap stayed flat at 150 and at 250 strokes. The knee was 400 strokes —
60
+ // median 16.6 ms, 11 frames over budget. So the number is ~6x conservative for
61
+ // performance on that machine, and it is kept for legibility.
62
+ //
63
+ // What that profile cost to get right, twice: rAF ran at a healthy 8.3 ms in a
64
+ // HIDDEN pane while a deliberately expensive repaint changed the median by
65
+ // nothing at all (ratio 1.00) — the pixels were never produced, so "paint is
66
+ // free here" would have been a true number about a false world. Visible, the
67
+ // same twin degraded 11x. And the first pass profiled `flow` with 9 strokes
68
+ // moving, not 60: the observed case was not the legal worst case. Measure this
69
+ // with both twins or do not quote it.
70
+ const MOTION_BUDGET = 60;
71
+ // Marks, so a card says what kind of thing it is before it is read.
72
+ //
73
+ // Crewforth's own is the ››› mark from assets/icon.svg, redrawn here rather than
74
+ // fetched — one <img> per node would be a request per node, and the shape is
75
+ // three strokes.
76
+ //
77
+ // Claude's built-in agents get a neutral burst, NOT Anthropic's logo. Shipping
78
+ // a vendor's trademark inside an MIT repo, on cards the panel draws itself,
79
+ // claims a relationship this project does not have. The shape reads as "not
80
+ // ours" without borrowing anyone's mark.
81
+ const MARKS = {
82
+ // Crewforth's own mark, from assets/icon.svg: three chevrons, the 200-unit
83
+ // artwork scaled by 0.08 into this 16-unit box (points and stroke widths
84
+ // alike). Its own palette, not the node's, so it looks like the mark rather
85
+ // than like the card.
86
+ kit: '<g fill="none" stroke-linecap="round" stroke-linejoin="round">'
87
+ + '<polyline points="3.52,4.64 6.88,8 3.52,11.36" stroke="#E5E7FB" stroke-width="1.68"/>'
88
+ + '<polyline points="6.4,4.64 9.76,8 6.4,11.36" stroke="#B9BEF9" stroke-width="1.68"/>'
89
+ + '<polyline points="9.28,4.64 12.64,8 9.28,11.36" stroke="#A78BFA" stroke-width="1.92"/></g>',
90
+ // Claude's mark, in Claude's colour. Rays of uneven length and spacing —
91
+ // the irregularity is what makes it read as the mark rather than as a star.
92
+ builtin: '<g stroke="#D97757" stroke-width="1.45" stroke-linecap="round" fill="none"><path d="M8.00 6.50L8.00 1.10"/><path d="M7.21 6.73L5.03 3.25"/><path d="M6.65 7.34L2.07 5.11"/><path d="M6.51 8.16L2.83 8.54"/><path d="M6.82 8.92L2.56 12.25"/><path d="M7.49 9.41L6.15 13.07"/><path d="M8.31 9.47L9.37 14.46"/><path d="M9.04 9.08L11.61 11.74"/><path d="M9.46 8.36L14.70 9.67"/><path d="M9.43 7.54L13.33 6.27"/><path d="M8.88 6.79L11.76 2.82"/></g>',
93
+ workflow: '<g fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">'
94
+ + '<path d="M3 4.5h10M3 8h10M3 11.5h10"/></g>',
95
+ session: '<g fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">'
96
+ + '<path d="M3.5 4.5l3 3-3 3M8.5 11.5h4"/></g>',
97
+ };
98
+
99
+ /** Element helper. `el` is used as a local name for a node all over this file,
100
+ * so the helper is named for what it does instead. */
101
+ function mk(tag, cls, text) {
102
+ const n = document.createElement(tag);
103
+ if (cls) n.className = cls;
104
+ if (text != null) n.textContent = text;
105
+ return n;
106
+ }
107
+
108
+ const MIN_K = 0.06;
109
+ const MAX_K = 2.5;
110
+ // Padding fit() leaves around the graph, on each side. Named because the band
111
+ // search below has to score candidates with the same arithmetic fit() uses; two
112
+ // copies of the number is two numbers that drift.
113
+ const FIT_PAD = 60;
114
+ // The largest fit() will scale up to. Same reason.
115
+ const FIT_MAX = 1.2;
116
+
117
+ /* --------------------------------------------------------------- reading
118
+ Three readings of the same picture, chosen by how much of a card would
119
+ actually survive on screen.
120
+
121
+ The idea is not "hide things when zoomed out" — hiding alone still leaves an
122
+ unreadable title. What survives is redrawn at a constant *screen* size: its
123
+ world font-size grows as the canvas shrinks. That costs one custom property
124
+ and no per-node JS, which is why a band change is O(1) in node count. */
125
+
126
+ // 13px .cv-desc clears 9px on screen at 9/13 = 0.692.
127
+ const LOD_NEAR = 0.70;
128
+ // A screen-constant 11px mono line stops fitting the card: the content box is
129
+ // 204x80 world px (236-18-14, 104-12-12); six mono chars at a 6.6px advance
130
+ // need 40 screen px, so 204*k >= 40 => k >= 0.196. The horizontal bound binds.
131
+ const LOD_FAR = 0.20;
132
+ // A trackpad tremor must not reband 250 cards.
133
+ const LOD_HYST = 0.025;
134
+ // Past this many drawn nodes, words are the noise floor. Count is the axis that
135
+ // says "there are too many words on this canvas"; zoom is the axis that says
136
+ // "the words are too small". Both have the same remedy, so both pick `far`.
137
+ const LABEL_BUDGET = 40;
138
+ // What the HUD calls each reading, so a viewer knows detail was traded rather
139
+ // than lost and that one wheel notch brings it back.
140
+ const LOD_WORD = { near: 'detail', mid: 'titles', far: 'marks' };
141
+ const LOD_RANK = { far: 0, mid: 1, near: 2 };
142
+
143
+ // Folding is driven by whether the graph fits, not by group size. A session
144
+ // with one 6-agent workflow should show all six; a session with 250 should
145
+ // arrive folded. Below this many nodes nothing is folded at all.
146
+ const COMFORTABLE_NODES = 34;
147
+
148
+ export class Canvas {
149
+ constructor(root, { onSelect } = {}) {
150
+ this.root = root;
151
+ this.onSelect = onSelect ?? (() => {});
152
+ this.view = { x: 80, y: 80, k: 1 };
153
+ this.nodes = new Map(); // id -> data
154
+ this.pos = new Map(); // id -> {x, y}
155
+ this.pinned = new Set(); // ids the user has placed by hand
156
+ this.els = new Map(); // id -> element
157
+ this.edgeEls = new Map(); // edge key -> <path>
158
+ this.edges = [];
159
+ // Ids that arrived on the last poll. An edge into one of these draws
160
+ // itself; every other edge is left alone, so unfolding a group of 105 does
161
+ // not set 105 animations running at once.
162
+ this.newborn = new Set();
163
+ this.palette = { map: {}, unknown: '#94a3c8' };
164
+ this.sessionKey = null;
165
+ this.selected = null;
166
+ this.firstRender = true;
167
+ this.collapsed = new Set();
168
+ // How many nodes the last layout actually placed. The reading band is a
169
+ // function of this as well as of zoom, and it is cached rather than
170
+ // recounted because applyView() runs on every frame of a pan.
171
+ this.drawn = 0;
172
+ // Which way delegation reads. Down is the default because a session
173
+ // handing work to agents feels like work moving downward; sideways suits
174
+ // deep chains better, so it is a preference rather than a decision.
175
+ this.flow = readFlow();
176
+
177
+ this.#build();
178
+ this.#wire();
179
+ // #renderNode runs before the first fit() inside render(), so without this
180
+ // there is one paint with --k unset, and every calc() that reads it drops.
181
+ this.applyView();
182
+ this.root.dataset.flow = this.flow;
183
+ this.root.querySelector('[data-act="flow"]').textContent = this.flow === 'down' ? '⇅' : '⇄';
184
+ }
185
+
186
+ #build() {
187
+ this.root.classList.add('cv-root');
188
+ this.root.innerHTML = `
189
+ <div class="cv-viewport">
190
+ <svg class="cv-edges" aria-hidden="true"><defs></defs><g class="cv-edge-g"></g></svg>
191
+ <div class="cv-nodes"></div>
192
+ </div>
193
+ <div class="cv-hud">
194
+ <button class="cv-btn" data-act="expand" title="Expand every group">⊞</button>
195
+ <button class="cv-btn" data-act="collapse" title="Fold every group">⊟</button>
196
+ <button class="cv-btn cv-alarm" data-act="failed" title="Go to the next failed branch" hidden>⚠</button>
197
+ <button class="cv-btn" data-act="flow" title="Switch layout direction">⇅</button>
198
+ <button class="cv-btn" data-act="fit" title="Fit to view">⤢</button>
199
+ <button class="cv-btn" data-act="relayout" title="Re-run auto layout">⟲</button>
200
+ <span class="cv-zoom">100%</span>
201
+ </div>
202
+ <div class="cv-empty" hidden></div>`;
203
+
204
+ this.viewport = this.root.querySelector('.cv-viewport');
205
+ this.svg = this.root.querySelector('.cv-edges');
206
+ this.edgeG = this.root.querySelector('.cv-edge-g');
207
+ this.nodeLayer = this.root.querySelector('.cv-nodes');
208
+ this.zoomLabel = this.root.querySelector('.cv-zoom');
209
+ this.emptyEl = this.root.querySelector('.cv-empty');
210
+ this.alarmBtn = this.root.querySelector('.cv-alarm');
211
+ }
212
+
213
+ /* ------------------------------------------------------------ input */
214
+
215
+ #wire() {
216
+ let panning = null;
217
+
218
+ this.root.addEventListener('pointerdown', (e) => {
219
+ if (e.button !== 0) return; // right/middle must not pan
220
+ if (e.target.closest('.cv-node') || e.target.closest('.cv-hud')) return;
221
+ panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
222
+ this.root.setPointerCapture(e.pointerId);
223
+ this.root.classList.add('cv-panning');
224
+ });
225
+
226
+ this.root.addEventListener('pointermove', (e) => {
227
+ if (!panning) return;
228
+ this.view.x = panning.x + (e.clientX - panning.px);
229
+ this.view.y = panning.y + (e.clientY - panning.py);
230
+ this.applyView();
231
+ });
232
+
233
+ const endPan = () => { panning = null; this.root.classList.remove('cv-panning'); };
234
+ this.root.addEventListener('pointerup', endPan);
235
+ this.root.addEventListener('pointercancel', endPan);
236
+
237
+ this.root.addEventListener('wheel', (e) => {
238
+ e.preventDefault();
239
+ const r = this.root.getBoundingClientRect();
240
+ const mx = e.clientX - r.left;
241
+ const my = e.clientY - r.top;
242
+
243
+ // deltaMode 1 is lines and 2 is pages; Firefox reports ~3 where Chrome
244
+ // reports ~100, which made zoom imperceptible and pan crawl.
245
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1;
246
+ const dY = e.deltaY * unit;
247
+ const dX = e.deltaX * unit;
248
+
249
+ if (e.ctrlKey || e.metaKey || Math.abs(dY) > Math.abs(dX)) {
250
+ const k = clamp(this.view.k * Math.exp(-dY * 0.0016), MIN_K, MAX_K);
251
+ // Zoom about the cursor: the world point under it must not move.
252
+ this.view.x = mx - (mx - this.view.x) * (k / this.view.k);
253
+ this.view.y = my - (my - this.view.y) * (k / this.view.k);
254
+ this.view.k = k;
255
+ } else {
256
+ this.view.x -= dX;
257
+ this.view.y -= dY;
258
+ }
259
+ this.applyView();
260
+ }, { passive: false });
261
+
262
+ this.root.querySelector('.cv-hud').addEventListener('click', (e) => {
263
+ const act = e.target.closest('[data-act]')?.dataset.act;
264
+ if (act === 'fit') this.fit();
265
+ if (act === 'relayout') { this.pinned.clear(); this.#persist(); this.layout(true); this.fit(); }
266
+ if (act === 'flow') this.setFlow(this.flow === 'down' ? 'right' : 'down');
267
+ if (act === 'failed') this.gotoFailed();
268
+ if (act === 'expand') { this.collapsed.clear(); this.#redraw(); }
269
+ if (act === 'collapse') {
270
+ for (const n of this.nodes.values()) if (this.hiddenCount(n.id) > 0) this.collapsed.add(n.id);
271
+ this.#redraw();
272
+ }
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Turn `view` into pixels. Public because it is the single place that does
278
+ * so — the wheel, the pan, fit() and the constructor all come through here,
279
+ * and so does anything that wants to drive the view without a pointer.
280
+ *
281
+ * It publishes two things the stylesheet reads: the scale, so a surviving
282
+ * label can be drawn at a constant *screen* size, and the reading band. A
283
+ * band change never re-lays out, re-fits or re-renders a node — every card
284
+ * responds through the cascade, which is what keeps this O(1) in node count
285
+ * and makes it impossible for a band to move the world that decides it.
286
+ */
287
+ applyView() {
288
+ const { x, y, k } = this.view;
289
+ this.viewport.style.transform = `translate(${x}px, ${y}px) scale(${k})`;
290
+ this.root.style.setProperty('--k', String(k));
291
+ const band = this.#band(k);
292
+ this.root.dataset.lod = band;
293
+ // Say when it was the crowd rather than the zoom that traded the words
294
+ // away, because the remedy for that one is folding, not scrolling.
295
+ const crowded = band === 'far' && k >= LOD_FAR;
296
+ this.zoomLabel.textContent = `${Math.round(k * 100)}% · ${LOD_WORD[band]}`
297
+ + (crowded ? ` (${this.drawn})` : '');
298
+ }
299
+
300
+ /** Which reading the canvas is in. Hysteresis is applied only on the way
301
+ * *up*, so a jitter sitting on a boundary cannot reband 250 cards twice a
302
+ * second; dropping detail is always immediate. */
303
+ #band(k) {
304
+ if (this.drawn > LABEL_BUDGET) return 'far';
305
+ const now = LOD_RANK[this.root.dataset.lod] ?? LOD_RANK.near;
306
+ const near = LOD_NEAR + (now < LOD_RANK.near ? LOD_HYST : 0);
307
+ const mid = LOD_FAR + (now < LOD_RANK.mid ? LOD_HYST : 0);
308
+ return k >= near ? 'near' : k >= mid ? 'mid' : 'far';
309
+ }
310
+
311
+ /* ----------------------------------------------------------- layout */
312
+
313
+ /**
314
+ * Fold the fewest, largest groups needed to make the graph readable.
315
+ *
316
+ * Runs once when a session opens. Small sessions are left fully open — the
317
+ * point is to rescue the ones that do not fit, not to hide work by default.
318
+ * Everything folded stays one click away and says how much it is holding.
319
+ */
320
+ #autoFold() {
321
+ const total = this.nodes.size;
322
+ if (total <= COMFORTABLE_NODES) return;
323
+
324
+ const groups = [...this.nodes.values()]
325
+ .filter((n) => n.kind === 'workflow' || (n.kind === 'agent' && this.hiddenCount(n.id) > 0))
326
+ .map((n) => ({ id: n.id, size: this.hiddenCount(n.id) }))
327
+ .filter((g) => g.size > 1)
328
+ .sort((a, b) => b.size - a.size);
329
+
330
+ let visible = total;
331
+ for (const g of groups) {
332
+ if (visible <= COMFORTABLE_NODES) break;
333
+ this.collapsed.add(g.id);
334
+ visible -= g.size;
335
+ }
336
+ }
337
+
338
+ /** Nodes that are actually drawn: everything except what a folded ancestor
339
+ * is hiding. Hidden nodes keep their data — they are folded, not dropped. */
340
+ visible() {
341
+ const hiddenBy = (id) => {
342
+ let cur = this.nodes.get(id);
343
+ let guard = 0;
344
+ while (cur && cur.parentId && guard++ < 24) {
345
+ if (this.collapsed.has(cur.parentId)) return true;
346
+ cur = this.nodes.get(cur.parentId);
347
+ }
348
+ return false;
349
+ };
350
+ return [...this.nodes.values()].filter((n) => !hiddenBy(n.id));
351
+ }
352
+
353
+ /** How many nodes a folded node is holding back, transitively. */
354
+ hiddenCount(id) {
355
+ let n = 0;
356
+ const walk = (pid) => {
357
+ for (const c of this.nodes.values()) {
358
+ if (c.parentId !== pid) continue;
359
+ n += 1;
360
+ walk(c.id);
361
+ }
362
+ };
363
+ walk(id);
364
+ return n;
365
+ }
366
+
367
+ // Depth downward, kind across. Eight agents in one undifferentiated fan told
368
+ // the reader nothing about which were alike; grouped by type, with the type
369
+ // named above its group, the same eight read as three Explores, a Plan, and
370
+ // so on. Groups keep their own small grid so one large kind does not push
371
+ // every other kind off the screen.
372
+ //
373
+ // A depth level then WRAPS. Laying every group of a depth in one unbounded
374
+ // row made the world a ribbon — 8624x1246 at 250 nodes — and fit() is
375
+ // width-bound at every size against a ribbon, so twelve agents already
376
+ // arrived at 34% and the type line was 3.7px on screen. The level is packed
377
+ // into bands instead, and how many groups go in a band is chosen by the same
378
+ // arithmetic fit() uses, so the layout optimises the number the reader
379
+ // actually feels.
380
+ //
381
+ // Nothing here reads this.view. Positions are never a function of zoom —
382
+ // that invariant is what makes the reading bands oscillation-proof.
383
+ layout(force = false) {
384
+ const byDepth = new Map();
385
+ let drawn = 0;
386
+ for (const n of this.visible()) {
387
+ const d = n.kind === 'session' ? 0 : (n.spawnDepth ?? 1);
388
+ if (!byDepth.has(d)) byDepth.set(d, []);
389
+ byDepth.get(d).push(n);
390
+ drawn += 1;
391
+ }
392
+ this.drawn = drawn;
393
+
394
+ const down = this.flow === 'down';
395
+ const NODE_A = down ? NODE_W : NODE_H; // size across a band
396
+ const NODE_B = down ? NODE_H : NODE_W; // size along the depth axis
397
+
398
+ // Read once per call. The band search needs the pane, and asking for it per
399
+ // group would be one forced layout per group on a 250-node graph.
400
+ const r = this.root.getBoundingClientRect();
401
+ const paneW = Math.max(2 * FIT_PAD + NODE_A, r.width || 800);
402
+ const paneH = Math.max(2 * FIT_PAD + NODE_B, r.height || 600);
403
+
404
+ const levels = [...byDepth.entries()].sort((a, b) => a[0] - b[0]).map(([, list]) => {
405
+ // One group per kind. Workflows are their own kind; the session is alone
406
+ // at its depth and needs no label.
407
+ const groups = new Map();
408
+ for (const n of list) {
409
+ const key = n.kind === 'session' ? '\u0000session'
410
+ : n.kind === 'workflow' ? 'workflow'
411
+ : (n.agentType ?? 'unknown');
412
+ if (!groups.has(key)) groups.set(key, []);
413
+ groups.get(key).push(n);
414
+ }
415
+
416
+ // Biggest kinds first, then alphabetically, so the picture is stable
417
+ // across refreshes rather than following arrival order.
418
+ const ordered = [...groups.entries()].sort(
419
+ (a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]),
420
+ );
421
+
422
+ // Measure first: every band is centred on its own width, so each group's
423
+ // span has to be known before any node is placed.
424
+ const measured = ordered.map(([key, members]) => {
425
+ const cols = groupCols(members.length, NODE_A, NODE_B);
426
+ const rows = Math.ceil(members.length / cols);
427
+ return { key, members, cols, rows, span: cols * NODE_A + (cols - 1) * SIBLING_GAP };
428
+ });
429
+ return { measured, plans: bandPlans(measured, NODE_B) };
430
+ });
431
+
432
+ // Pick a packing per level against the fit of the WHOLE graph, not of the
433
+ // level alone. Scored alone, a tall single-column level always wins — it is
434
+ // narrow, and it is only the other levels stacked under it that make that a
435
+ // bad trade. Levels are few and each has a handful of candidates, so a few
436
+ // rounds of coordinate descent over the real objective is cheap and lands
437
+ // on the same answer every time.
438
+ const fitOf = (choice) => {
439
+ let w = 0;
440
+ let h = 0;
441
+ for (let i = 0; i < levels.length; i += 1) {
442
+ const p = levels[i].plans[choice[i]];
443
+ w = Math.max(w, p.w);
444
+ h += p.h;
445
+ }
446
+ h += Math.max(0, levels.length - 1) * DEPTH_GAP;
447
+ return clamp(Math.min((paneW - 2 * FIT_PAD) / w, (paneH - 2 * FIT_PAD) / h, FIT_MAX), MIN_K, MAX_K);
448
+ };
449
+ const choice = levels.map((L) => {
450
+ let best = 0;
451
+ for (let i = 1; i < L.plans.length; i += 1) {
452
+ if (soloFit(L.plans[i], paneW, paneH) > soloFit(L.plans[best], paneW, paneH) + 1e-9) best = i;
453
+ }
454
+ return best;
455
+ });
456
+ for (let round = 0; round < 4; round += 1) {
457
+ let moved = false;
458
+ for (let i = 0; i < levels.length; i += 1) {
459
+ let bestJ = choice[i];
460
+ let bestK = fitOf(choice);
461
+ for (let j = 0; j < levels[i].plans.length; j += 1) {
462
+ if (j === choice[i]) continue;
463
+ const trial = choice.slice();
464
+ trial[i] = j;
465
+ const k = fitOf(trial);
466
+ if (k > bestK + 1e-9) { bestK = k; bestJ = j; }
467
+ }
468
+ if (bestJ !== choice[i]) { choice[i] = bestJ; moved = true; }
469
+ }
470
+ if (!moved) break;
471
+ }
472
+
473
+ let cursor = 0; // position along the depth axis
474
+ levels.forEach((L, li) => {
475
+ const { per } = L.plans[choice[li]];
476
+ let bandTop = cursor;
477
+ for (let i = 0; i < L.measured.length; i += per) {
478
+ const band = L.measured.slice(i, i + per);
479
+ // Each band is centred on its own width rather than on the widest one,
480
+ // so a level does not look ragged.
481
+ let across = -(band.reduce((t, g) => t + g.span, 0) + (band.length - 1) * GROUP_GAP) / 2;
482
+ let rows = 0;
483
+ for (const g of band) {
484
+ g.members.sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0) || a.id.localeCompare(b.id));
485
+ g.members.forEach((n, idx) => {
486
+ const col = idx % g.cols;
487
+ const row = Math.floor(idx / g.cols);
488
+ const a = across + col * (NODE_A + SIBLING_GAP);
489
+ const b = bandTop + row * (NODE_B + SIBLING_GAP);
490
+ // Only a hand-placed node keeps its position. Freezing auto-placed
491
+ // ones too was the bug: the row is re-centred as siblings arrive, so
492
+ // nodes laid out against an older, shorter row overlapped the new
493
+ // ones by half a card.
494
+ if (!force && this.pinned.has(n.id)) return;
495
+ this.pos.set(n.id, down ? { x: a, y: b } : { x: b, y: a });
496
+ });
497
+ across += g.span + GROUP_GAP;
498
+ rows = Math.max(rows, g.rows);
499
+ }
500
+ bandTop += rows * (NODE_B + SIBLING_GAP) - SIBLING_GAP + GROUP_GAP;
501
+ }
502
+ // The next depth clears the whole stack of bands, not one row of it.
503
+ cursor = bandTop - GROUP_GAP + DEPTH_GAP;
504
+ });
505
+ }
506
+
507
+ /** Re-fit only while the layout is still automatic. Once nodes have been
508
+ * placed by hand, moving the view under them is not helpful. */
509
+ fitIfUntouched() {
510
+ if (!this.pinned.size) this.fit();
511
+ }
512
+
513
+ fit() {
514
+ if (!this.pos.size) return;
515
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
516
+ for (const p of this.pos.values()) {
517
+ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
518
+ maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + NODE_H);
519
+ }
520
+ const r = this.root.getBoundingClientRect();
521
+ const pad = FIT_PAD;
522
+ const k = clamp(Math.min((r.width - pad * 2) / (maxX - minX), (r.height - pad * 2) / (maxY - minY), FIT_MAX), MIN_K, MAX_K);
523
+ this.view.k = k;
524
+ this.view.x = (r.width - (maxX - minX) * k) / 2 - minX * k;
525
+ this.view.y = (r.height - (maxY - minY) * k) / 2 - minY * k;
526
+ this.applyView();
527
+ }
528
+
529
+ /* -------------------------------------------------------- rendering */
530
+
531
+ setPalette(p) { if (p?.map) this.palette = p; }
532
+
533
+ /** The one colour a node answers to. The card paints itself with it and the
534
+ * edge into the card is stroked with it, which is what makes a branch
535
+ * traceable by colour alone. */
536
+ nodeColor(n) {
537
+ if (!n) return this.palette.unknown;
538
+ return KIND_COLOR[n.kind] ?? this.palette.map[n.agentType]?.hex ?? this.palette.unknown;
539
+ }
540
+
541
+ /** Switch between top-down and left-to-right. Hand-placed nodes are cleared:
542
+ * positions arranged for one direction are meaningless in the other. */
543
+ setFlow(flow) {
544
+ if (flow !== 'down' && flow !== 'right') return;
545
+ this.flow = flow;
546
+ writeFlow(flow);
547
+ this.root.dataset.flow = flow;
548
+ this.root.querySelector('[data-act="flow"]').textContent = flow === 'down' ? '⇅' : '⇄';
549
+ this.pinned.clear();
550
+ this.#persist();
551
+ this.layout(true);
552
+ const vis = new Set(this.visible().map((n) => n.id));
553
+ for (const [id, el] of this.els) {
554
+ if (!vis.has(id)) { el.remove(); this.els.delete(id); }
555
+ }
556
+ for (const n of this.visible()) this.#renderNode(n);
557
+ this.#renderEdges();
558
+ this.fit();
559
+ this.#syncHud();
560
+ }
561
+
562
+ setSession(sessionId) {
563
+ if (this.sessionKey === sessionId) return;
564
+ this.sessionKey = sessionId;
565
+ this.nodes.clear(); this.pos.clear(); this.pinned.clear(); this.collapsed.clear();
566
+ this.els.clear(); this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
567
+ this.edgeEls.clear(); this.newborn.clear();
568
+ this.selected = null;
569
+ this.firstRender = true;
570
+ this.#restore();
571
+ }
572
+
573
+ render(graph) {
574
+ if (!graph?.nodes?.length) {
575
+ this.emptyEl.hidden = false;
576
+ this.emptyEl.innerHTML =
577
+ '<strong>No agents yet</strong><div>This session has not delegated to a subagent. ' +
578
+ 'The moment it does, a node appears here.</div>';
579
+ // els must go with the DOM. Leaving stale elements behind made
580
+ // #renderNode believe they were still mounted, so nodes never returned.
581
+ this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
582
+ this.nodes.clear(); this.els.clear(); this.pos.clear();
583
+ this.edgeEls.clear(); this.newborn.clear();
584
+ this.selected = null;
585
+ this.drawn = 0;
586
+ this.onSelect(null);
587
+ this.applyView();
588
+ this.#syncHud();
589
+ return;
590
+ }
591
+ this.emptyEl.hidden = true;
592
+
593
+ const seen = new Set();
594
+ let born = false;
595
+
596
+ for (const n of graph.nodes) {
597
+ seen.add(n.id);
598
+ const isNew = !this.nodes.has(n.id);
599
+ this.nodes.set(n.id, n);
600
+ if (isNew) { born = true; this.newborn.add(n.id); }
601
+ }
602
+
603
+ for (const id of [...this.nodes.keys()]) {
604
+ if (!seen.has(id)) {
605
+ this.nodes.delete(id);
606
+ this.els.get(id)?.remove();
607
+ this.els.delete(id);
608
+ this.pos.delete(id);
609
+ this.pinned.delete(id);
610
+ // The inspector must not keep describing a node that is gone.
611
+ if (this.selected === id) { this.selected = null; this.onSelect(null); }
612
+ }
613
+ }
614
+
615
+ if (this.firstRender) this.#autoFold();
616
+ this.layout();
617
+ this.edges = graph.edges ?? [];
618
+
619
+ const vis = new Set(this.visible().map((n) => n.id));
620
+ for (const [id, el] of this.els) {
621
+ if (!vis.has(id)) { el.remove(); this.els.delete(id); }
622
+ }
623
+ for (const n of this.visible()) this.#renderNode(n);
624
+ this.#renderEdges();
625
+
626
+ // Fit once when a session opens, so the graph is never half off-screen.
627
+ // After that the view is the user's: re-fitting under them as agents
628
+ // appear would yank the canvas mid-drag.
629
+ if (this.firstRender) {
630
+ this.firstRender = false;
631
+ this.fit();
632
+ } else if (born && !this.pinned.size) {
633
+ this.fit();
634
+ }
635
+ // Neither branch above is guaranteed to run, and both the drawn count and
636
+ // the failure count can have changed on any poll.
637
+ this.applyView();
638
+ this.#syncHud();
639
+ }
640
+
641
+ #renderNode(n) {
642
+ let el = this.els.get(n.id);
643
+ const fresh = !el;
644
+
645
+ if (fresh) {
646
+ el = document.createElement('div');
647
+ el.className = 'cv-node';
648
+ el.dataset.id = n.id;
649
+ el.innerHTML = `
650
+ <span class="cv-port cv-in"></span>
651
+ <span class="cv-port cv-out"></span>
652
+ <div class="cv-accent"></div>
653
+ <div class="cv-head">
654
+ <button class="cv-fold" type="button" hidden></button>
655
+ <svg class="cv-mark" viewBox="0 0 16 16" aria-hidden="true"></svg>
656
+ <span class="cv-type"></span>
657
+ <span class="cv-chip"></span>
658
+ </div>
659
+ <div class="cv-desc"></div>
660
+ <div class="cv-foot"></div>`;
661
+ // The card's children never change identity, so they are found once per
662
+ // element instead of six times per element per poll — at 250 nodes on a
663
+ // 2s poll that is 45,000 selector runs a minute for a fixed answer.
664
+ el.parts = {
665
+ fold: el.querySelector('.cv-fold'),
666
+ mark: el.querySelector('.cv-mark'),
667
+ type: el.querySelector('.cv-type'),
668
+ chip: el.querySelector('.cv-chip'),
669
+ desc: el.querySelector('.cv-desc'),
670
+ foot: el.querySelector('.cv-foot'),
671
+ };
672
+ this.nodeLayer.append(el);
673
+ this.els.set(n.id, el);
674
+ this.#makeDraggable(el, n.id);
675
+ el.parts.fold.addEventListener('click', (ev) => {
676
+ ev.stopPropagation();
677
+ if (this.collapsed.has(n.id)) this.collapsed.delete(n.id); else this.collapsed.add(n.id);
678
+ this.#redraw();
679
+ });
680
+ // A drag ends with a click on the same element. Without this guard every
681
+ // reposition also toggled selection and fired a report fetch.
682
+ el.addEventListener('click', () => {
683
+ if (el.dataset.suppressClick === '1') { el.dataset.suppressClick = '0'; return; }
684
+ // A container opens on click — the fold control is a 20px target and
685
+ // the card is the obvious one, so a folded workflow used to look like a
686
+ // dead end with its agents nowhere to be found.
687
+ //
688
+ // The session node is NOT one of those, even though it has children.
689
+ // It is the conversation, and clicking it should open that; folding it
690
+ // is what its caret is for. Treating every parent alike meant a click
691
+ // on the session hid the whole graph.
692
+ const node = this.nodes.get(n.id);
693
+ if (node && node.kind !== 'session' && this.hiddenCount(n.id) > 0) {
694
+ if (this.collapsed.has(n.id)) this.collapsed.delete(n.id); else this.collapsed.add(n.id);
695
+ this.#redraw();
696
+ return;
697
+ }
698
+ this.#select(n.id);
699
+ });
700
+ // Birth animation, only for nodes that actually just appeared.
701
+ el.classList.add('cv-born');
702
+ requestAnimationFrame(() => requestAnimationFrame(() => el.classList.remove('cv-born')));
703
+ }
704
+
705
+ const p = this.pos.get(n.id) ?? { x: 0, y: 0 };
706
+ el.style.transform = `translate(${p.x}px, ${p.y}px)`;
707
+ el.dataset.kind = n.kind;
708
+ el.dataset.status = n.status ?? 'unknown';
709
+ el.classList.toggle('cv-selected', this.selected === n.id);
710
+
711
+ const known = n.kind !== 'agent' || Boolean(this.palette.map[n.agentType]);
712
+ el.style.setProperty('--node-color', this.nodeColor(n));
713
+ // What motion says about this node, separated from the raw status so that
714
+ // `killed` and `stopped` read like the failures they are instead of like
715
+ // two more words nothing has a rule for.
716
+ el.dataset.state = FLOW_STATE[n.status] ?? 'unknown';
717
+
718
+ const typeText = n.kind === 'session' ? 'SESSION'
719
+ : n.kind === 'workflow' ? 'WORKFLOW'
720
+ : (n.agentType ?? 'unknown agent');
721
+ const typeEl = el.parts.type;
722
+ typeEl.textContent = typeText;
723
+ // Two names the stylesheet draws counter-scaled, so that what survives a
724
+ // zoom-out is drawn at a constant size on the reader's screen rather than
725
+ // shrunk into grey. The far one carries the running tool, because "what is
726
+ // it doing" is exactly that string.
727
+ typeEl.dataset.short = n.kind === 'agent' ? shortType(n.agentType ?? 'unknown') : typeText;
728
+ typeEl.dataset.far = typeEl.dataset.short
729
+ + (n.status === 'running' && n.lastTool ? ` \u25b8 ${n.lastTool}` : '');
730
+
731
+ // Whose agent this is, at a glance. The palette says where a type was
732
+ // declared; anything it does not know is drawn as unknown rather than
733
+ // guessed into one camp or the other.
734
+ const source = n.kind === 'session' ? 'session'
735
+ : n.kind === 'workflow' ? 'workflow'
736
+ : (this.palette.map[n.agentType]?.source ?? null);
737
+ const markEl = el.parts.mark;
738
+ markEl.innerHTML = MARKS[source === 'kit' ? 'kit' : source === 'builtin' ? 'builtin' : source] ?? MARKS.builtin;
739
+ markEl.classList.toggle('cv-mark-unknown', n.kind === 'agent' && !source);
740
+ markEl.setAttribute('aria-label', source === 'kit' ? 'Crewforth agent' : 'built-in agent');
741
+ el.dataset.source = source ?? 'unknown';
742
+ // An agent type Crewforth never declared is marked, not quietly coloured in.
743
+ typeEl.classList.toggle('cv-unknown', !known);
744
+
745
+ // Fold control, shown only where there is something to fold.
746
+ const kids = [...this.nodes.values()].filter((c) => c.parentId === n.id).length;
747
+ const fold = el.parts.fold;
748
+ if (kids) {
749
+ fold.hidden = false;
750
+ const folded = this.collapsed.has(n.id);
751
+ const hidden = folded ? this.hiddenCount(n.id) : 0;
752
+ fold.textContent = folded ? `▸ ${hidden}` : '▾';
753
+ fold.title = folded ? `Show ${hidden} hidden node(s)` : `Fold ${kids} child node(s)`;
754
+ el.classList.toggle('cv-folded', folded);
755
+ // Say what a click does, since for these nodes it is not selection.
756
+ if (n.kind === 'session') {
757
+ el.title = 'Click to open this conversation';
758
+ el.classList.remove('cv-container');
759
+ } else {
760
+ el.title = folded ? `Click to show ${hidden} node(s) inside` : 'Click to fold';
761
+ el.classList.add('cv-container');
762
+ }
763
+ } else {
764
+ fold.hidden = true;
765
+ el.classList.remove('cv-folded', 'cv-container');
766
+ el.title = '';
767
+ }
768
+
769
+ const chip = el.parts.chip;
770
+ chip.textContent = n.kind === 'session' ? `${n.turns ?? 0} turns`
771
+ : n.kind === 'workflow' ? `${n.members ?? 0} agents`
772
+ : (n.status ?? '?');
773
+ chip.dataset.status = n.status ?? 'unknown';
774
+
775
+ const desc = n.kind === 'session' ? (shortPath(n.cwd) || n.sessionId)
776
+ : n.kind === 'workflow' ? (n.workflowId ?? 'workflow run')
777
+ : (n.description ?? '—');
778
+ el.parts.desc.textContent = desc;
779
+
780
+ el.parts.foot.replaceChildren(...this.#footBits(n));
781
+
782
+ // The card's whole name, at every reading. The two smaller readings drop
783
+ // the type line to font-size:0 and redraw it from a pseudo-element, and
784
+ // neither of those is reliably exposed to a screen reader — so without this
785
+ // the far band would ship as an accessibility regression rather than as a
786
+ // rendering change.
787
+ el.setAttribute('aria-label', `${typeText}, ${n.status ?? 'unknown'}${desc ? `, ${desc}` : ''}`);
788
+ }
789
+
790
+ #footBits(n) {
791
+ const bits = [];
792
+ const add = (text, cls) => {
793
+ const s = document.createElement('span');
794
+ s.className = `cv-bit${cls ? ` ${cls}` : ''}`;
795
+ s.textContent = text;
796
+ bits.push(s);
797
+ };
798
+
799
+ if (n.kind === 'workflow') {
800
+ // The breakdown is the point of a folded group: how it went, not just
801
+ // how big it was.
802
+ for (const [st, c] of Object.entries(n.byStatus ?? {})) add(`${c} ${st}`, st === 'running' ? 'cv-live' : null);
803
+ if (n.tokens) add(fmtTokens(n.tokens));
804
+ if (n.durationMs != null) add(fmtDuration(n.durationMs));
805
+ return bits;
806
+ }
807
+
808
+ if (n.kind === 'session') {
809
+ if (n.model) add(n.model);
810
+ if (n.tokens != null) add(`${fmtTokens(n.tokens)} ctx`);
811
+ if (n.gitBranch) add(n.gitBranch, 'cv-branch');
812
+ return bits;
813
+ }
814
+
815
+ if (n.status === 'running' && n.lastTool) add(`▸ ${n.lastTool}`, 'cv-live');
816
+ else if (n.lastTool) add(n.lastTool);
817
+ if (n.toolCount) add(`${n.toolCount} calls`);
818
+ if (n.tokens != null) add(fmtTokens(n.tokens));
819
+ if (n.durationMs != null) add(fmtDuration(n.durationMs));
820
+ if (n.errors) add(`${n.errors} err`, 'cv-err');
821
+ return bits;
822
+ }
823
+
824
+ // Edges are kept and updated, never rebuilt.
825
+ //
826
+ // Replacing the whole layer each pass was the reason motion could not live
827
+ // here: a CSS animation restarts when its element leaves the document, so a
828
+ // travelling dash jumped back to its start on every 2s poll and on every
829
+ // frame of a drag. Reusing the path element is what lets the stylesheet own
830
+ // the animation and JS own nothing but geometry.
831
+ #renderEdges() {
832
+ const vis = new Set(this.visible().map((n) => n.id));
833
+ const alive = new Set();
834
+ let moving = 0;
835
+
836
+ for (const e of this.edges) {
837
+ if (!vis.has(e.source) || !vis.has(e.target)) continue;
838
+ const a = this.pos.get(e.source);
839
+ const b = this.pos.get(e.target);
840
+ if (!a || !b) continue;
841
+
842
+ const key = `${e.source}\u0000${e.target}`;
843
+ alive.add(key);
844
+
845
+ // Out of the parent's outgoing port, into the child's incoming one.
846
+ // Control points are pushed along the flow axis so siblings fan out
847
+ // instead of stacking on one line.
848
+ const down = this.flow === 'down';
849
+ const x1 = down ? a.x + NODE_W / 2 : a.x + NODE_W;
850
+ const y1 = down ? a.y + NODE_H : a.y + NODE_H / 2;
851
+ const x2 = down ? b.x + NODE_W / 2 : b.x;
852
+ const y2 = down ? b.y : b.y + NODE_H / 2;
853
+ const d = Math.max(46, Math.abs((down ? y2 - y1 : x2 - x1)) * 0.55);
854
+
855
+ let path = this.edgeEls.get(key);
856
+ const fresh = !path;
857
+ if (fresh) {
858
+ path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
859
+ path.setAttribute('class', 'cv-edge');
860
+ this.edgeEls.set(key, path);
861
+ this.edgeG.append(path);
862
+ }
863
+
864
+ path.setAttribute('d', down
865
+ ? `M ${x1} ${y1} C ${x1} ${y1 + d}, ${x2} ${y2 - d}, ${x2} ${y2}`
866
+ : `M ${x1} ${y1} C ${x1 + d} ${y1}, ${x2 - d} ${y2}, ${x2} ${y2}`);
867
+
868
+ // The draw-in has to cover the whole curve without measuring it —
869
+ // getTotalLength() forces a synchronous layout, once per edge. A cubic is
870
+ // never longer than its control polygon, so that bound is computed from
871
+ // the numbers already in hand and handed to CSS as a length.
872
+ path.style.setProperty('--edge-len', `${Math.ceil(Math.hypot(x2 - x1, y2 - y1) + 2 * d)}px`);
873
+
874
+ const target = this.nodes.get(e.target);
875
+ const parent = this.nodes.get(e.source);
876
+ const state = FLOW_STATE[target?.status] ?? 'unknown';
877
+ path.dataset.status = target?.status ?? 'unknown';
878
+ path.dataset.state = state;
879
+
880
+ // A workflow's twelve members are one dispatch, not twelve unrelated
881
+ // decisions. Blending each member's own colour toward the container's
882
+ // pulls the whole bundle into one hue family while still leaving every
883
+ // line traceable back to the card it feeds.
884
+ const member = parent?.kind === 'workflow';
885
+ path.dataset.group = member ? 'member' : 'spawn';
886
+ const own = this.nodeColor(target);
887
+ path.style.stroke = state === 'failed' ? 'var(--cv-fail)'
888
+ : member ? mixHex(own, this.nodeColor(parent), 0.45)
889
+ : own;
890
+
891
+ if (state === 'live' || state === 'waking') moving += 1;
892
+
893
+ // Only an edge into a node that just arrived draws itself. Unfolding a
894
+ // group is not an arrival, so opening a 105-agent workflow reveals it
895
+ // rather than performing it.
896
+ if (fresh && this.newborn.has(e.target)) {
897
+ path.classList.add('cv-drawing');
898
+ setTimeout(() => path.classList.remove('cv-drawing'), DRAW_MS);
899
+ }
900
+ }
901
+
902
+ for (const [key, path] of this.edgeEls) {
903
+ if (alive.has(key)) continue;
904
+ path.remove();
905
+ this.edgeEls.delete(key);
906
+ }
907
+ this.newborn.clear();
908
+
909
+ // Only travelling strokes are counted. The card pulse rides the same flag,
910
+ // but it animates opacity on a pseudo-element and costs the compositor
911
+ // almost nothing, so charging a running agent twice — once for its edge and
912
+ // once for its card — would halve the budget for no reason anyone measured.
913
+ this.root.dataset.motion = moving > MOTION_BUDGET ? 'still' : 'flow';
914
+
915
+ // The SVG plane must cover every node, including negative coordinates.
916
+ let minX = 0, minY = 0, maxX = 0, maxY = 0;
917
+ for (const p of this.pos.values()) {
918
+ minX = Math.min(minX, p.x - 100); minY = Math.min(minY, p.y - 100);
919
+ maxX = Math.max(maxX, p.x + NODE_W + 100); maxY = Math.max(maxY, p.y + NODE_H + 100);
920
+ }
921
+ this.svg.setAttribute('viewBox', `${minX} ${minY} ${maxX - minX} ${maxY - minY}`);
922
+ this.svg.style.left = `${minX}px`;
923
+ this.svg.style.top = `${minY}px`;
924
+ this.svg.style.width = `${maxX - minX}px`;
925
+ this.svg.style.height = `${maxY - minY}px`;
926
+ }
927
+
928
+ /** Re-lay out and repaint after folding changed what is visible. */
929
+ #redraw() {
930
+ this.layout(true);
931
+ const vis = new Set(this.visible().map((x) => x.id));
932
+ for (const [id, el] of this.els) if (!vis.has(id)) { el.remove(); this.els.delete(id); }
933
+ for (const v of this.visible()) this.#renderNode(v);
934
+ this.#renderEdges();
935
+ // Unfolding 105 agents puts most of them off-screen; pull the view back to
936
+ // what was just revealed, unless the user has arranged things by hand.
937
+ this.fitIfUntouched();
938
+ // fitIfUntouched() is a no-op once a node has been dragged, and folding is
939
+ // exactly the thing that changes the drawn count the reading band is a
940
+ // function of. Republish it either way.
941
+ this.applyView();
942
+ this.#syncHud();
943
+ }
944
+
945
+ /** What the HUD can say only after a render: how many branches went wrong.
946
+ * With three red marks among 250 you can still be panned away from them. */
947
+ #syncHud() {
948
+ const bad = this.#failedIds();
949
+ this.alarmBtn.hidden = bad.length === 0;
950
+ this.alarmBtn.textContent = `\u26a0 ${bad.length}`;
951
+ this.alarmBtn.title = bad.length === 1
952
+ ? 'Go to the failed branch'
953
+ : `Go to the next of ${bad.length} failed branches`;
954
+ }
955
+
956
+ /** Drawn nodes that did not finish on their own terms, in a stable order so
957
+ * that stepping through them twice visits them in the same sequence. */
958
+ #failedIds() {
959
+ return this.visible()
960
+ .filter((n) => FLOW_STATE[n.status] === 'failed')
961
+ .map((n) => n.id)
962
+ .sort((a, b) => a.localeCompare(b));
963
+ }
964
+
965
+ /**
966
+ * Step to the next failed branch, wrapping. Public for the same reason
967
+ * applyView() is: the HUD button is its only caller in the page, and it has
968
+ * to be drivable without a pointer.
969
+ *
970
+ * Selection is set rather than toggled — #select() flips a node that is
971
+ * already chosen, which would make the button a no-op the second time it is
972
+ * pressed on a lone failure.
973
+ */
974
+ gotoFailed() {
975
+ const bad = this.#failedIds();
976
+ if (!bad.length) return;
977
+ const at = bad.indexOf(this.selected);
978
+ const id = bad[(at + 1) % bad.length];
979
+ this.#centreOn(id);
980
+ this.selected = id;
981
+ for (const [nid, el] of this.els) el.classList.toggle('cv-selected', nid === id);
982
+ this.onSelect(this.nodes.get(id) ?? null);
983
+ }
984
+
985
+ /** Put one node in the middle of the pane without changing the zoom — the
986
+ * reading band must not move because the view panned. */
987
+ #centreOn(id) {
988
+ const p = this.pos.get(id);
989
+ if (!p) return;
990
+ const r = this.root.getBoundingClientRect();
991
+ this.view.x = r.width / 2 - (p.x + NODE_W / 2) * this.view.k;
992
+ this.view.y = r.height / 2 - (p.y + NODE_H / 2) * this.view.k;
993
+ this.applyView();
994
+ }
995
+
996
+ /** Drop the selection without pretending a node was clicked. */
997
+ clearSelection() {
998
+ this.selected = null;
999
+ for (const el of this.els.values()) el.classList.remove('cv-selected');
1000
+ }
1001
+
1002
+ #select(id) {
1003
+ this.selected = this.selected === id ? null : id;
1004
+ for (const [nid, el] of this.els) el.classList.toggle('cv-selected', nid === this.selected);
1005
+ this.onSelect(this.selected ? this.nodes.get(this.selected) : null);
1006
+ }
1007
+
1008
+ /* ---------------------------------------------------------- dragging */
1009
+
1010
+ #makeDraggable(el, id) {
1011
+ let drag = null;
1012
+
1013
+ el.addEventListener('pointerdown', (e) => {
1014
+ if (e.button !== 0) return;
1015
+ e.stopPropagation(); // do not pan the canvas too
1016
+ const p = this.pos.get(id) ?? { x: 0, y: 0 };
1017
+ drag = { px: e.clientX, py: e.clientY, x: p.x, y: p.y, moved: false };
1018
+ el.setPointerCapture(e.pointerId);
1019
+ el.classList.add('cv-dragging');
1020
+ });
1021
+
1022
+ el.addEventListener('pointermove', (e) => {
1023
+ if (!drag) return;
1024
+ const dx = (e.clientX - drag.px) / this.view.k;
1025
+ const dy = (e.clientY - drag.py) / this.view.k;
1026
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) drag.moved = true;
1027
+ this.pos.set(id, { x: drag.x + dx, y: drag.y + dy });
1028
+ el.style.transform = `translate(${drag.x + dx}px, ${drag.y + dy}px)`;
1029
+ this.#renderEdges();
1030
+ });
1031
+
1032
+ const stop = () => {
1033
+ if (!drag) return;
1034
+ if (drag.moved) { this.pinned.add(id); this.#persist(); el.dataset.suppressClick = '1'; }
1035
+ drag = null;
1036
+ el.classList.remove('cv-dragging');
1037
+ };
1038
+ el.addEventListener('pointerup', stop);
1039
+ el.addEventListener('pointercancel', stop);
1040
+ }
1041
+
1042
+ /* -------------------------------------------------------- persistence
1043
+ Hand-placed positions are the user's work and outlive a refresh. Storage
1044
+ can be unavailable (private windows, blocked site data), so every access
1045
+ is guarded and the canvas falls back to auto layout. */
1046
+
1047
+ #key() { return `crewforth-studio-layout:${this.flow}:${this.sessionKey}`; }
1048
+
1049
+ #persist() {
1050
+ if (!this.sessionKey) return; // no session, nothing to key the layout to
1051
+ try {
1052
+ const out = {};
1053
+ for (const id of this.pinned) {
1054
+ const p = this.pos.get(id);
1055
+ if (p) out[id] = [Math.round(p.x), Math.round(p.y)];
1056
+ }
1057
+ localStorage.setItem(this.#key(), JSON.stringify(out));
1058
+ } catch { /* positions stay for this page view only */ }
1059
+ }
1060
+
1061
+ #restore() {
1062
+ if (!this.sessionKey) return;
1063
+ try {
1064
+ const raw = localStorage.getItem(this.#key());
1065
+ if (!raw) return;
1066
+ for (const [id, [x, y]] of Object.entries(JSON.parse(raw))) {
1067
+ this.pos.set(id, { x, y });
1068
+ this.pinned.add(id);
1069
+ }
1070
+ } catch { /* start from auto layout */ }
1071
+ }
1072
+ }
1073
+
1074
+ /* -------------------------------------------------------------- helpers */
1075
+
1076
+ function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
1077
+
1078
+ /**
1079
+ * How many columns one kind's own grid gets.
1080
+ *
1081
+ * Four until the group is big enough that four columns would make a tower —
1082
+ * a session that spawned 250 agents of a single type is 63 rows deep at four
1083
+ * across, and no amount of band packing rescues one group. Past ~36 members the
1084
+ * count goes square-ish in the flow's own aspect instead. Below that this
1085
+ * returns exactly what MAX_PER_GROUP_ROW always returned.
1086
+ */
1087
+ function groupCols(count, nodeA, nodeB) {
1088
+ const square = Math.ceil(Math.sqrt(Math.max(1, count) * (nodeB / nodeA)));
1089
+ return Math.min(count, Math.max(MAX_PER_GROUP_ROW, square));
1090
+ }
1091
+
1092
+ /**
1093
+ * Every way a depth level's groups can be packed into bands, and the box each
1094
+ * packing needs. `per` is groups per band; bands stack along the depth axis.
1095
+ */
1096
+ function bandPlans(measured, nodeB) {
1097
+ const plans = [];
1098
+ for (let per = 1; per <= Math.max(1, measured.length); per += 1) {
1099
+ let w = 0;
1100
+ let h = 0;
1101
+ let bands = 0;
1102
+ for (let i = 0; i < measured.length; i += per) {
1103
+ const band = measured.slice(i, i + per);
1104
+ w = Math.max(w, band.reduce((t, g) => t + g.span, 0) + (band.length - 1) * GROUP_GAP);
1105
+ h += Math.max(...band.map((g) => g.rows)) * (nodeB + SIBLING_GAP) - SIBLING_GAP;
1106
+ bands += 1;
1107
+ }
1108
+ plans.push({ per, w: Math.max(1, w), h: Math.max(1, h + (bands - 1) * GROUP_GAP) });
1109
+ }
1110
+ return plans;
1111
+ }
1112
+
1113
+ /** What a level would fit at with the pane to itself. Only a starting guess —
1114
+ * the real objective is the whole graph, which layout() optimises. */
1115
+ function soloFit(plan, paneW, paneH) {
1116
+ return clamp(
1117
+ Math.min((paneW - 2 * FIT_PAD) / plan.w, (paneH - 2 * FIT_PAD) / plan.h, FIT_MAX),
1118
+ MIN_K, MAX_K,
1119
+ );
1120
+ }
1121
+
1122
+ /** An agent's short name: the crew- prefix and the -expert / -agent suffix dropped. */
1123
+ function shortType(t) {
1124
+ return String(t ?? '').replace(/^crew-/, '').replace(/-(expert|agent)$/, '');
1125
+ }
1126
+
1127
+ /** `#rgb` and `#rrggbb` to three channels, or null for anything else — a
1128
+ * palette entry that is not a plain hex is left alone rather than mangled. */
1129
+ function rgb(h) {
1130
+ const s = String(h ?? '').trim();
1131
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
1132
+ if (!m) return null;
1133
+ const x = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
1134
+ return [0, 2, 4].map((i) => parseInt(x.slice(i, i + 2), 16));
1135
+ }
1136
+
1137
+ /** `t` of the way from `a` toward `b`. Done here rather than with color-mix()
1138
+ * because the result is also what the edge reports to anything reading it. */
1139
+ function mixHex(a, b, t) {
1140
+ const pa = rgb(a);
1141
+ const pb = rgb(b);
1142
+ if (!pa || !pb) return a;
1143
+ const hex = pa.map((v, i) => Math.round(v + (pb[i] - v) * t).toString(16).padStart(2, '0'));
1144
+ return `#${hex.join('')}`;
1145
+ }
1146
+
1147
+ function readFlow() {
1148
+ try {
1149
+ const v = localStorage.getItem('crewforth-studio-flow');
1150
+ return v === 'right' ? 'right' : 'down';
1151
+ } catch { return 'down'; }
1152
+ }
1153
+
1154
+ function writeFlow(v) {
1155
+ try { localStorage.setItem('crewforth-studio-flow', v); } catch { /* blocked storage */ }
1156
+ }
1157
+
1158
+ function fmtTokens(t) {
1159
+ if (t == null) return '';
1160
+ if (t < 1000) return `${t}`;
1161
+ if (t < 1_000_000) return `${(t / 1000).toFixed(t < 10_000 ? 1 : 0)}k`;
1162
+ return `${(t / 1_000_000).toFixed(2)}M`;
1163
+ }
1164
+
1165
+ function fmtDuration(ms) {
1166
+ const s = Math.round(ms / 1000);
1167
+ if (s < 60) return `${s}s`;
1168
+ const m = Math.floor(s / 60);
1169
+ return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
1170
+ }
1171
+
1172
+ function shortPath(p, max = 40) {
1173
+ if (!p) return '';
1174
+ const home = p.match(/^\/(Users|home)\/[^/]+/);
1175
+ const s = home ? `~${p.slice(home[0].length)}` : p;
1176
+ if (s.length <= max) return s;
1177
+ const keep = Math.floor((max - 1) / 2);
1178
+ return `${s.slice(0, keep)}…${s.slice(-keep)}`;
1179
+ }