Factory views: per-conversation agent tree with bill, guards, log and warnings (/factory), and a dashboard of all live conversations with routing shares…

Mods for Claude Code: small panes that show what your agents do. They never block or rewrite a tool call; at most they warn.
Needs Claude Code 2.1.287 or later (function-hook mods, panes, Svg element).
| Mod | Command | What it shows | ||||
|---|---|---|---|---|---|---|
factory | /factory, /factory <title or id> | Dashboard of every conversation (model, effort, context %, cost today, running subagents, PR/MR and CI, waiting on you). Click one for its agent tree: main thread, each subagent and Workflow node with model, effort, tokens and estimated cost, the bill per model and per turn, refused tool calls, the session log and warnings (subagent without an explicit model, Opus doing read-only search, big context without handoff/RESUME.md, timer tools). | ||||
control | `/control [setup\ | usage\ | practices\ | history] [scan]` | SETUP: everything that shapes a session (settings, env, permissions, global and repo CLAUDE.md, agents, skills, hook scripts, mods, scheduled tasks, MCP servers, plugins, memory), with hidden rules and any blocker (hook, deny rule, mod deny path) at the top. USAGE: 1/7/30-day model, effort and delegation mix from the transcripts, each metric against a target. PRACTICES and HISTORY: your rules file, followed or not, and its version history. | |
ci | /ci [refresh], `/ci route <repo\ | session\ | here> <backend-id>, /ci clear <target>` | CI routing: the CI backends from your config (GitHub Actions, GitLab.com shared runners, a self-hosted runner, VM runners still coming), whether each is usable now (token present yes/no, runners online, running/pending jobs, Actions per routed repo), and which repo or conversation uses which. A lead conversation or you set the route; each conversation sees its route in the status line and as a short note in its system prompt. | ||
scenarios | `/scenarios [S1..] [prompt\ | agent\ | workflow\ | frontmatter\ | env]` | Scénarios d'agents: presets of roles -> model, effort, unit (session, sub-conversation, Workflow agent(), Agent subagent) and max parallel (S1 Standard V6, S2 Économe, S3 Qualité max, S4 Nuit, plus yours from a JSON file). For each role, the exact settings (Agent call, agent() opts, agent-file frontmatter, env vars) and the documented limits, each with its doc URL and "non vérifié" where the docs say nothing; a Copy button for a ready-to-paste snippet. Warn-only toast when an Agent call names no model. |
guide | /mods-guide [onglet ou section] | Mode d'emploi des mods, in French: tabs (Sommaire, Bases, Événements, Interface, Configuration, Installer et activer, Tester, Écrire un mod pas à pas, Référence API, Nos mods, FAQ), foldable categories, and one card per mod of this repo built live from its plugin.json and hooks module. Same content in MODE-EMPLOI.md. |
factory and control read local files only (transcripts under ~/.claude/projects, the desktop app's session files, your settings) through a Python scanner (standard library) and draw the result. Dollar figures are estimates from token counts and the rate table in each mod's config.
What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.
| Mod | Verdict | Why |
|---|---|---|
| factory | keep | The only view of the agent tree with models, efforts and cost. Next: feed it from hook events (agent.spawn, turn.start) instead of scanning files. |
| control | keep | The "nothing hidden" proof: every rule source, env variable and blocker in one place. Its metrics and practices still follow the V5 rules and are being rewritten for V6. |
| coordinator | merge into factory | Linked sub-conversations remove most lane collisions; only the "green PR not merged" and "waiting on you" rows are worth keeping, as a strip in factory. |
| context band (cc-control band, context-meter) | merge into a V6 band | Small and correct: context size against the hand-off threshold, read-only. Not written yet. |
| cc-control guards | drop | They refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out. |
| ci | new (Oct 2026) | Which CI backend a repo or conversation should use, and whether it works right now, in one place instead of in rules text. |
| scenarios | new (Oct 2026) | Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts. |
| guide | new (Oct 2026) | How mods work and how to write one, plus a card per mod that reads the mod itself. |
| cost-tracker, activity-pane, launchpad, night | drop | Wrong numbers for new model ids, superseded by factory, or built on timer wake-ups. |
| V4 pages and watcher | drop | Dead since early Oct, replaced by factory and the app's own agent view. |
Copy or symlink a mod folder to ~/.claude/mods/<name>:
git clone https://github.com/julian-passebecq/claude-mod.git
# copy
cp -r claude-mod/mods/factory ~/.claude/mods/factory
# or symlink (Windows: mklink /D, as admin or with developer mode)
ln -s "$PWD/claude-mod/mods/factory" ~/.claude/mods/factory
Then load it, either for every session with the environment (in ~/.claude/settings.json under env, folders separated by ; on Windows, : elsewhere):
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/factory;~/.claude/mods/control;~/.claude/mods/ci;~/.claude/mods/scenarios;~/.claude/mods/guide"
or as a dev-mod in the desktop app for one session. Python 3 must be on PATH (the scanners); gh / glab are used, when present, for PR/MR and CI state.
Each mod has a config file next to it (factory.config.json, config.json): rates, thresholds, colors, labels and paths are set there, nothing is hard-coded. factory also deep-merges ~/.claude/effort-board/factory.config.json if you have one. In control/config.json, set paths.projects_root (the folder that holds your repos) and the optional doc paths.
cp claude-mod/mods/ci/ci.config.example.json ~/.claude/ci.config.json # then fill the <PLACEHOLDERS>
python -I ~/.claude/mods/ci/scanner/ci.py show --check # same data as /ci, from any shell
python -I ~/.claude/mods/ci/scanner/ci.py route ~/code/my-repo gitlab-local --by lead
--config, then $CI_MOD_CONFIG, then ~/.claude/ci.config.json, then ci.config.json next to the mod, then the shipped example. Each backend has id, label, kind (github | gitlab), host, runner_tag, status (active | coming-soon | disabled) and notes; optional group, concurrent, minutes_month, public_only, check_runners, pending_projects. A value still in <angle brackets> counts as unset. Labels and colors are in labels.gh auth status, glab auth status, or the env variable names listed under hosts); a token value is never printed or stored. With a GitLab token: runners with the backend's tag online/offline and their running jobs (pending jobs for the projects in pending_projects). With gh: Actions availability for the repos routed to a GitHub backend. Missing tools or tokens show as unknown. Results are cached (status_cache, cache_minutes); /ci refresh checks now.routing_file (default ~/.claude/ci-routing.json), one entry per repo path or session id with backend, who set it and when. A session route wins over a repo route; a worktree inside a routed repo follows the repo. status_line and prompt_note in the config turn the status-line entry and the system-prompt note off.PATH; gh and glab are optional.deny, no rewriting of tool calls or prompts.ci also writes its routing file, on /ci route and /ci clear).gh / glab for PR state and the GitLab API for runner status (ci).python -I -m unittest discover -s mods/control/tests -p "test_*.py"
python -I -m unittest discover -s mods/ci/tests -p "test_*.py"
node --test mods/factory/tests/logic.test.mjs mods/scenarios/tests/logic.test.mjs mods/guide/tests/guide.test.mjs
node mods/guide/scripts/build.mjs # regenerate MODE-EMPLOI.md (table of contents, Nos mods cards)
# hook and pane tests, with the Claude Code test kit:
claude plugin test mods/factory
claude plugin test mods/control
claude plugin test mods/ci
claude plugin test mods/scenarios
claude plugin test mods/guide
# before every push (public repo): no private paths, tokens, e-mails or transcripts
python -I tools/confidentiality_scan.py .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is ignored since Claude Code 2.1.287 (mods are on by default, overview). GitHub Actions (.github/workflows/tests.yml) runs the Python and node tests and the confidentiality scan; the test-kit suites need the claude CLI and run locally.
control's reference-window test replays the author's own transcripts and only runs with CONTROL_REFERENCE=1.
MIT
hooks/register.mjs 349 lines1// factory v0.1 - two views over the Claude Code transcripts:
2// /factory [title or id] one conversation: agent tree, bill, approvals/guards, session log, warnings
3// one pane: /factory opens the dashboard of all conversations; click one for its tree, ◀ to come back
4// The data comes from scanner/factory_scan.py (incremental tail reads, cache in the effort-board); this file runs it on
5// a timer while a pane is open and draws. Decisions and numbers live in logic.mjs (pure, node --test).
6import {
7 deepMerge, dashboard, conversation, pickSession, scanArgs, fmtTokens, fmtUsd, hhmm, short, family,
8} from './logic.mjs'
9import { treeSvg, dashSvg, treeAlt, dashAlt } from './svg.mjs'
10
11let CFG = null
12const D = { zoom: 1, scan: null, target: null, self: null, busy: false, error: '', at: 0, open: false, view: 'all', filter: 'all' }
13
14async function homeDir($) {
15 return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
16}
17
18// FACTORY_BOARD overrides the effort-board folder (tests, dry runs); default ~/.claude/effort-board.
19async function board($) {
20 const o = await $.env.get('FACTORY_BOARD')
21 return o ? o.replace(/\\/g, '/') : (await homeDir($)) + '/.claude/effort-board'
22}
23
24async function readJson($, path) {
25 try {
26 if (!(await $.fs.exists(path))) return null
27 return JSON.parse(await $.fs.read(path))
28 } catch {
29 return null
30 }
31}
32
33// factory.config.json beside the mod, deep-merged with ~/.claude/effort-board/factory.config.json when present.
34async function loadConfig($) {
35 const base = (await readJson($, $.plugin.root.replace(/\\/g, '/') + '/factory.config.json')) || {}
36 const over = await readJson($, (await board($)) + '/factory.config.json')
37 CFG = deepMerge(base, over || {})
38 return CFG
39}
40
41async function rescan($) {
42 if (D.busy) return
43 D.busy = true
44 try {
45 const cfg = CFG || (await loadConfig($))
46 const root = $.plugin.root.replace(/\\/g, '/')
47 const argv = [cfg.python || 'python', ...scanArgs(cfg, {
48 script: root + '/scanner/factory_scan.py', cache: (await board($)) + '/factory_cache.json', detail: D.target || D.self,
49 })]
50 const r = await $.process.run(argv, { timeoutMs: 180000 })
51 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
52 D.scan = JSON.parse(r.stdout)
53 D.error = ''
54 D.at = await $.clock.now()
55 } catch (err) {
56 D.error = String(err && err.message ? err.message : err).slice(0, 300)
57 } finally {
58 D.busy = false
59 }
60 $.ui.invalidate('ui.render')
61}
62
63// ---- drawing helpers ---------------------------------------------------------------------------------------------
64
65function colorOf(key) {
66 return (CFG && CFG.colors && CFG.colors[key]) || undefined
67}
68
69function legend(key) {
70 return (CFG && CFG.legends && CFG.legends[key]) || key
71}
72
73function T(el, text, o = {}) {
74 const p = { children: [String(text)] }
75 if (o.color) p.color = o.color
76 if (o.bold) p.bold = true
77 if (o.dim) p.dimColor = true
78 if (o.wrap !== false) p.wrap = o.wrap || 'truncate'
79 return el.Text(p)
80}
81
82function row(el, parts, o = {}) {
83 return el.Box({ flexDirection: 'row', gap: 1, ...o, children: parts.filter(Boolean) })
84}
85
86function heading(el, text) {
87 return el.Box({ marginTop: 1, children: [T(el, text, { bold: true })] })
88}
89
90function modelText(el, model, effort) {
91 return T(el, `${short(model)}${effort ? ' · ' + effort : ''}`, { color: colorOf(family(model)) })
92}
93
94function statusText(el, st) {
95 return T(el, legend(st), { color: colorOf(st) })
96}
97
98function agentBox(el, n, wide) {
99 const lines = [
100 row(el, [T(el, n.type, { bold: true }), statusText(el, n.status), n.bg ? T(el, 'bg', { dim: true }) : null]),
101 row(el, [modelText(el, n.model, n.effort), n.explicit ? null : T(el, 'no explicit model', { color: colorOf('warn') })]),
102 row(el, [T(el, `${fmtTokens(n.tokens)} tok`), T(el, fmtUsd(n.cost))]),
103 T(el, n.task || '-', { dim: true }),
104 ]
105 if (n.architect) lines.splice(1, 0, T(el, `architect: ${n.architect}`, { color: colorOf('architect'), bold: true }))
106 if (n.phase) lines.push(T(el, `phase ${n.phase}`, { dim: true }))
107 return el.Box({
108 flexDirection: 'column', borderStyle: 'round', borderColor: colorOf(n.architect ? 'architect' : n.status), paddingX: 1,
109 width: wide ? 38 : undefined, marginRight: 1, children: lines,
110 })
111}
112
113function nothing(el, text) {
114 return T(el, text, { dim: true })
115}
116
117// ---- views -------------------------------------------------------------------------------------------------------
118
119function drawConversation($, el, wide) {
120 const head = []
121 if (D.error) head.push(T(el, `scanner: ${D.error}`, { color: colorOf('bad'), wrap: 'wrap' }))
122 const d = D.scan && D.scan.detail
123 if (!d) return el.Box({ flexDirection: 'column', children: [...head, nothing(el, D.busy || !D.scan ? 'Scanning transcripts…' : 'Conversation not found in the last scan.')] })
124 const c = conversation(d, CFG, D.at)
125 const m = c.main
126 const kids = []
127 kids.push(row(el, [T(el, c.title, { bold: true }), T(el, `${c.repo}${c.branch ? ' · ' + c.branch : ''}`, { dim: true }),
128 T(el, `updated ${hhmm(D.at)}${D.busy ? ' …' : ''}`, { dim: true })]))
129 // main thread box
130 kids.push(el.Box({
131 flexDirection: 'column', borderStyle: 'round', borderColor: colorOf(m.ctxLevel), paddingX: 1, children: [
132 row(el, [T(el, legend('main'), { bold: true }), m.live ? statusText(el, 'running') : null]),
133 row(el, [modelText(el, m.model, m.effort), T(el, `context ${fmtTokens(m.ctx)}/${fmtTokens(m.window)} (${m.ctxPct}%)`, { color: colorOf(m.ctxLevel) })]),
134 row(el, [T(el, `cost so far ${fmtUsd(m.cost)} (${CFG.cost_label || 'estimate'})`), T(el, `main ${fmtUsd(m.ownCost)}`, { dim: true }),
135 c.pr ? T(el, `PR #${c.pr.number} ${c.pr.state || ''} CI ${c.pr.ci || '?'}`, { color: colorOf(c.pr.ci === 'failed' ? 'bad' : 'dim') }) : null]),
136 c.waiting ? T(el, 'waiting on Julian', { color: colorOf('warn'), bold: true }) : null,
137 ].filter(Boolean),
138 }))
139 // tree
140 kids.push(heading(el, `${legend('tree')} · ${c.agents.length} subagent(s), ${c.workflows.length} workflow(s)`))
141 if (c.agents.length) kids.push(el.Box({ flexDirection: wide ? 'row' : 'column', flexWrap: 'wrap', paddingLeft: 2, children: c.agents.map((n) => agentBox(el, n, wide)) }))
142 for (const w of c.workflows) {
143 kids.push(row(el, [T(el, `└ workflow ${w.name}`, { bold: true }), T(el, `${w.nodes.length} agents, ${w.running} running, ${fmtTokens(w.tokens)} tok, ${fmtUsd(w.cost)}`, { dim: true })], { paddingLeft: 2 }))
144 kids.push(el.Box({ flexDirection: wide ? 'row' : 'column', flexWrap: 'wrap', paddingLeft: 4, children: w.nodes.map((n) => agentBox(el, n, wide)) }))
145 }
146 if (!c.agents.length && !c.workflows.length) kids.push(nothing(el, ' no subagents yet'))
147 // warnings
148 kids.push(heading(el, `${legend('warnings')} (${c.warnings.length})`))
149 kids.push(...(c.warnings.length ? c.warnings.map((w) => T(el, `• ${w.text}`, { color: colorOf(w.level), wrap: 'wrap' })) : [nothing(el, 'none')]))
150 // bill
151 kids.push(heading(el, `${legend('bill')}: ${fmtUsd(c.total)}`))
152 for (const [model, b] of Object.entries(c.bill).sort((a, b) => b[1].cost - a[1].cost)) {
153 kids.push(row(el, [T(el, short(model).padEnd(12), { color: colorOf(family(model)) }), T(el, fmtUsd(b.cost).padStart(8)),
154 T(el, `${fmtTokens(b.tokens)} tok, ${b.calls} calls`, { dim: true })]))
155 }
156 kids.push(T(el, 'per turn (main + subagents started in it):', { dim: true }))
157 for (const t of c.turns) {
158 kids.push(row(el, [T(el, hhmm(t.ts), { dim: true }), T(el, fmtUsd(t.cost).padStart(8)), T(el, t.kind.padEnd(6), { dim: true }),
159 T(el, `${t.agents ? `[${t.agents} ag] ` : ''}${t.text}`)]))
160 }
161 // guards
162 kids.push(heading(el, `${legend('guards')} (${c.guards.length})`))
163 kids.push(...(c.guards.length ? c.guards.map((g) => row(el, [T(el, hhmm(g.ts), { dim: true }), T(el, g.src, { dim: true }), T(el, g.text, { color: colorOf('warn') })])) : [nothing(el, 'none seen')]))
164 // log
165 kids.push(heading(el, `${legend('log')} (last ${c.events.length})`))
166 for (const e of c.events) kids.push(row(el, [T(el, hhmm(e[0]), { dim: true }), T(el, e[1].padEnd(7), { dim: true }), T(el, e[2])]))
167 kids.push(row(el, [
168 el.Button({ children: ['Refresh'], onPress: () => { void rescan($) } }),
169 el.Button({ children: ['◀ All conversations'], onPress: () => { void go($, 'all') } }),
170 ], { marginTop: 1 }))
171 return el.Box({ flexDirection: 'column', children: [...head, ...kids] })
172}
173
174function drawDashboard($, el, wide) {
175 const kids = []
176 if (D.error) kids.push(T(el, `scanner: ${D.error}`, { color: colorOf('bad'), wrap: 'wrap' }))
177 if (!D.scan) return el.Box({ flexDirection: 'column', children: [...kids, nothing(el, 'Scanning transcripts…')] })
178 const g = dashboard(D.scan, CFG, D.at)
179 kids.push(row(el, [T(el, `${g.rows.length} conversations · today ${g.today}`, { bold: true }),
180 T(el, `${fmtUsd(g.totalCost)} today (${CFG.cost_label || 'estimate'}), ${g.totalCalls} calls`), T(el, `updated ${hhmm(D.at)}${D.busy ? ' …' : ''}`, { dim: true })]))
181 // routing shares
182 kids.push(heading(el, 'Model share today vs V4.8 routing targets (calls)'))
183 kids.push(row(el, g.share.map((s) => T(el, `${s.family} ${Math.round(s.share * 100)}% (target ${s.target}) ${fmtUsd(s.cost)}`,
184 { color: colorOf(s.ok ? family(s.family) : 'bad'), bold: !s.ok }))))
185 kids.push(heading(el, 'Totals today per model'))
186 for (const [m, b] of Object.entries(g.byModel).sort((a, b) => b[1].cost - a[1].cost)) {
187 kids.push(row(el, [T(el, short(m).padEnd(12), { color: colorOf(family(m)) }), T(el, fmtUsd(b.cost).padStart(8)), T(el, `${fmtTokens(b.tokens)} tok, ${b.calls} calls`, { dim: true })]))
188 }
189 kids.push(heading(el, 'Totals today per project'))
190 for (const [p, b] of Object.entries(g.byProject).filter(([, b]) => b.cost > 0).sort((a, b) => b[1].cost - a[1].cost)) {
191 kids.push(row(el, [T(el, p.padEnd(26)), T(el, fmtUsd(b.cost).padStart(8)), T(el, `${b.sessions} conv, ${fmtTokens(b.tokens)} tok`, { dim: true })]))
192 }
193 kids.push(heading(el, 'Conversations'))
194 kids.push(row(el, ['', 'title', 'repo · branch', 'model · effort', 'ctx', 'today', 'subs', 'PR/MR · CI', 'Julian?', 'last problem']
195 .map((h) => T(el, h, { dim: true }))))
196 for (const r of g.rows) {
197 kids.push(el.Box({
198 key: r.cli, flexDirection: wide ? 'row' : 'column', gap: 1, children: [
199 el.Button({ children: [r.live ? '●' : '○'], onPress: () => { void go($, 'conv', r.cli) } }),
200 T(el, r.title.slice(0, 34).padEnd(34), { bold: r.live }),
201 T(el, `${r.repo}${r.branch ? ' · ' + r.branch.replace(/^claude\//, '') : ''}`.slice(0, 40), { dim: true }),
202 modelText(el, r.model, r.effort),
203 T(el, `${r.ctxPct}%`, { color: colorOf(r.ctxLevel) }),
204 T(el, fmtUsd(r.costToday)),
205 T(el, `${r.running}/${r.subs}`, { color: r.running ? colorOf('running') : undefined }),
206 T(el, r.pr ? `#${r.pr.number} ${r.pr.state} ${r.pr.ci}` : '-', { color: r.pr && r.pr.ci === 'failed' ? colorOf('bad') : undefined }),
207 T(el, r.waiting ? legend('waiting_yes') : legend('waiting_no'), { color: r.waiting ? colorOf('warn') : colorOf('dim'), bold: r.waiting }),
208 T(el, r.problem || '-', { color: colorOf(r.problemLevel) }),
209 ],
210 }))
211 }
212 kids.push(T(el, 'Press ● / ○ to open that conversation’s agent tree.', { dim: true }))
213 return el.Box({ flexDirection: 'column', children: kids })
214}
215
216// Dashboard filters (buttons above the list) and the clickable conversation list, drawn with native elements.
217const FILTERS = [['all', 'All'], ['live', 'Live'], ['problems', 'Problems'], ['julian', 'Waiting on Julian']]
218
219export function filterRows(rows, f) {
220 if (f === 'live') return rows.filter((r) => r.live)
221 if (f === 'problems') return rows.filter((r) => r.problem && r.problemLevel !== 'ok')
222 if (f === 'julian') return rows.filter((r) => r.waiting)
223 return rows
224}
225
226function convList($, el, g) {
227 const rows = filterRows(g.rows, D.filter)
228 const kids = [el.Box({ flexDirection: 'row', gap: 1, marginTop: 1, marginBottom: 1, children: FILTERS.map(([k, label]) => el.Button({
229 children: [`${label} (${filterRows(g.rows, k).length})`], variant: D.filter === k ? 'primary' : 'secondary',
230 onPress: () => { D.filter = k; $.ui.invalidate('ui.render') },
231 })) })]
232 for (const r of rows) {
233 kids.push(el.Box({
234 key: 'conv-' + r.cli, flexDirection: 'column', borderStyle: 'round', borderColor: colorOf(r.waiting ? 'warn' : r.live ? 'running' : 'dim'),
235 paddingX: 1, marginBottom: 1, children: [
236 row(el, [
237 el.Button({ children: [`${r.live ? '● ' : ''}${r.title}`], onPress: () => { void go($, 'conv', r.cli) } }),
238 T(el, `${r.repo}${r.branch ? ' · ' + r.branch.replace(/^claude\//, '') : ''}`, { dim: true }),
239 modelText(el, r.model, r.effort),
240 ], { alignItems: 'center' }),
241 row(el, [
242 T(el, `ctx ${r.ctxPct}%`, { color: colorOf(r.ctxLevel) }),
243 T(el, `${fmtUsd(r.costToday)} today`),
244 T(el, `agents ${r.running}/${r.subs}`, { color: r.running ? colorOf('running') : colorOf('dim') }),
245 r.pr ? T(el, `PR #${r.pr.number} ${r.pr.state} · CI ${r.pr.ci}`, { color: colorOf(r.pr.ci === 'failed' ? 'bad' : r.pr.ci === 'success' ? 'ok' : 'dim') }) : T(el, 'no PR', { dim: true }),
246 r.waiting ? T(el, 'waiting on Julian', { color: colorOf('warn'), bold: true }) : null,
247 ]),
248 r.problem ? T(el, r.problem, { color: colorOf(r.problemLevel) }) : null,
249 ].filter(Boolean),
250 }))
251 }
252 if (!rows.length) kids.push(nothing(el, 'nothing in this filter'))
253 return kids
254}
255
256// Size the Svg explicitly (without it the desktop letterboxes it into a small image): the pane body width
257// (bodyColumns x svg.px_per_column) times the zoom, height kept to scale.
258function sized(el, source, alt, props) {
259 const w0 = Number((source.match(/viewBox="0 0 (\d+)/) || [])[1]) || 760
260 const h0 = Number((source.match(/viewBox="0 0 \d+ (\d+)/) || [])[1]) || 600
261 const cols = props && Number(props.bodyColumns)
262 const pxCol = (CFG.svg && CFG.svg.px_per_column) || 8
263 const fit = cols > 0 ? cols * pxCol : w0
264 const w = Math.max(320, Math.round(fit * D.zoom))
265 return el.Svg({ source, alt, isInteractive: true, width: w, height: Math.round(h0 * w / w0) })
266}
267
268function zoomButtons($, el) {
269 return [
270 el.Button({ children: ['−'], onPress: () => { D.zoom = Math.max(0.5, D.zoom - 0.25); $.ui.invalidate('ui.render') } }),
271 el.Button({ children: [`${Math.round(D.zoom * 100)}%`], onPress: () => { D.zoom = 1; $.ui.invalidate('ui.render') } }),
272 el.Button({ children: ['+'], onPress: () => { D.zoom = Math.min(3, D.zoom + 0.25); $.ui.invalidate('ui.render') } }),
273 ]
274}
275
276// Desktop: one pane. View 'all' = dashboard drawing + clickable conversation list; 'conv' = that conversation's tree.
277function drawSvg($, el, props) {
278 const conv = D.view === 'conv'
279 const kids = [el.Box({ flexDirection: 'row', gap: 1, marginBottom: 1, children: [
280 conv ? el.Button({ children: ['◀ All conversations'], variant: 'primary', onPress: () => { void go($, 'all') } })
281 : el.Button({ children: ['This conversation'], onPress: () => { void go($, 'conv', null) } }),
282 el.Button({ children: [D.busy ? 'Refreshing…' : 'Refresh'], onPress: () => { void rescan($) } }),
283 ...zoomButtons($, el),
284 ] })]
285 if (D.error) kids.push(T(el, `scanner: ${D.error}`, { color: colorOf('bad'), wrap: 'wrap' }))
286 if (conv) {
287 const d = D.scan.detail
288 if (d) {
289 const m = conversation(d, CFG, D.at)
290 kids.push(sized(el, treeSvg(m, CFG, D.at), treeAlt(m), props))
291 } else {
292 kids.push(nothing(el, D.busy ? 'Scanning transcripts…' : 'Conversation not found in the last scan.'))
293 }
294 } else {
295 const g = dashboard(D.scan, CFG, D.at)
296 kids.push(sized(el, dashSvg(g, CFG, D.at, (CFG.routing_targets || {}).metric || 'calls', { rows: false }), dashAlt(g), props))
297 kids.push(...convList($, el, g))
298 }
299 return el.Box({ flexDirection: 'column', children: kids })
300}
301
302// Navigate inside the one pane: 'all' (dashboard) or 'conv' (a conversation's tree; target null = this one).
303async function go($, view, target) {
304 D.view = view
305 if (target !== undefined) D.target = target === D.self ? null : target
306 $.ui.invalidate('ui.render')
307 await rescan($)
308}
309
310async function openPane($) {
311 D.open = true
312 await $.ui.open({ id: 'factory', title: 'Factory', focus: true, closeOnEscape: true })
313}
314
315export function register(on) {
316 on('session.start', async ($, e, next) => {
317 try { D.self = await $.session.id() } catch { D.self = null }
318 await loadConfig($).catch(() => { CFG = {} })
319 $.clock.every(Math.max(5, (CFG && CFG.refresh_s) || 20) * 1000, () => { if (D.open) void rescan($) })
320 try {
321 await $.command.register({ name: 'factory', description: 'Factory: dashboard of all conversations; click one for its agent tree (/factory <title or id> opens one directly)', immediate: true })
322 } catch { /* a name clash must not break the rest */ }
323 return next(e)
324 })
325
326 on('command.run', { command: 'factory' }, async ($, e) => {
327 await loadConfig($)
328 try { D.self = (await $.session.id()) || D.self } catch { /* keep the last one */ }
329 const q = String(e.args || '').trim()
330 await openPane($)
331 if (!q) { await go($, 'all'); return {} }
332 if (!D.scan) await rescan($)
333 const s = pickSession(D.scan && D.scan.sessions, q)
334 await go($, 'conv', s ? s.cli : q)
335 return {}
336 })
337
338 on('ui.close', { id: 'factory' }, async ($, e, next) => { D.open = false; return next(e) }).catch(() => undefined)
339
340 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
341 if (e.requestId !== 'factory') return next(e)
342 const el = $.ui.resolve(e)
343 const wide = e.surface !== 'terminal'
344 if (!CFG) CFG = {}
345 if (wide && el.Svg && D.scan) return drawSvg($, el, e.props)
346 return D.view === 'conv' ? drawConversation($, el, wide) : drawDashboard($, el, wide)
347 })
348}
349hooks/logic.mjs 280 lines1// factory - pure logic (no $, no I/O): config merge, pricing, warnings, and the models the two views draw.
2// Input is the JSON printed by scanner/factory_scan.py. Tested with node --test tests/logic.test.mjs.
3
4export const FAMILIES = ['opus', 'sonnet', 'haiku', 'fable']
5
6export function deepMerge(base, over) {
7 if (!over || typeof over !== 'object' || Array.isArray(over)) return over === undefined ? base : over
8 const out = { ...(base || {}) }
9 for (const [k, v] of Object.entries(over)) {
10 out[k] = v && typeof v === 'object' && !Array.isArray(v) && base && typeof base[k] === 'object' && !Array.isArray(base[k])
11 ? deepMerge(base[k], v) : v
12 }
13 return out
14}
15
16export function family(model) {
17 const m = String(model || '').toLowerCase()
18 return FAMILIES.find((f) => m.includes(f)) || 'other'
19}
20
21// Rate for a model id: exact id, else its family, else default.
22export function rateFor(model, cfg) {
23 const r = cfg.rates || {}
24 return r[model] || r[family(model)] || r.default || { in: 0, out: 0, cache_read: 0, cache_write_5m: 0, cache_write_1h: 0 }
25}
26
27// v = [in, out, cache_read, cache_write_5m, cache_write_1h, calls]
28export function costOf(model, v, cfg) {
29 const r = rateFor(model, cfg)
30 return ((v[0] || 0) * r.in + (v[1] || 0) * r.out + (v[2] || 0) * r.cache_read + (v[3] || 0) * r.cache_write_5m
31 + (v[4] || 0) * r.cache_write_1h) / 1e6
32}
33
34export function tokensOf(v) {
35 return (v[0] || 0) + (v[1] || 0) + (v[2] || 0) + (v[3] || 0) + (v[4] || 0)
36}
37
38// Sum a list of {model: vec} maps into {model: {tokens, cost, calls, out}}.
39export function billByModel(maps, cfg) {
40 const out = {}
41 for (const mp of maps) {
42 for (const [m, v] of Object.entries(mp || {})) {
43 const o = out[m] || (out[m] = { tokens: 0, cost: 0, calls: 0, out: 0 })
44 o.tokens += tokensOf(v)
45 o.cost += costOf(m, v, cfg)
46 o.calls += v[5] || 0
47 o.out += v[1] || 0
48 }
49 }
50 return out
51}
52
53export function sumCost(bill) {
54 return Object.values(bill).reduce((a, b) => a + b.cost, 0)
55}
56
57export function localDay(epochMs) {
58 const d = new Date(epochMs)
59 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
60}
61
62export function fmtTokens(n) {
63 if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
64 if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
65 if (n >= 1e3) return Math.round(n / 1e3) + 'K'
66 return String(n || 0)
67}
68
69export function fmtUsd(x) {
70 return '$' + (x >= 100 ? x.toFixed(0) : x.toFixed(2))
71}
72
73export function hhmm(ts) {
74 if (!ts) return '--:--'
75 const d = new Date(ts)
76 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
77}
78
79export function short(model) {
80 const m = String(model || '?')
81 return m.replace(/^claude-/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2')
82}
83
84export function repoOf(s) {
85 const p = String(s.origin || s.cwd || '').replace(/\\/g, '/')
86 const i = p.indexOf('/.claude/worktrees/')
87 const base = i >= 0 ? p.slice(0, i) : p
88 return base.split('/').filter(Boolean).pop() || '?'
89}
90
91export function titleOf(s) {
92 return s.app_title || s.title || String(s.cli || '').slice(0, 8)
93}
94
95export function architectReason(text, cfg) {
96 const a = cfg.architect || {}
97 for (const r of a.reasons || []) if (new RegExp(r.match, 'i').test(text || '')) return r.label
98 return a.fallback || 'other'
99}
100
101export function isArchitect(sub, cfg) {
102 return ((cfg.architect && cfg.architect.types) || ['architect']).includes(sub.type)
103}
104
105export function ctxPct(ctx, cfg) {
106 return Math.round((100 * (ctx || 0)) / (cfg.context_window || 400000))
107}
108
109export function ctxLevel(ctx, cfg) {
110 const p = (100 * (ctx || 0)) / (cfg.context_window || 400000)
111 const t = cfg.thresholds || {}
112 return p >= (t.ctx_bad_pct ?? 62.5) ? 'bad' : p >= (t.ctx_warn_pct ?? 50) ? 'warn' : 'ok'
113}
114
115function loopText(loops) {
116 return Object.entries(loops || {}).filter(([, n]) => n > 0).map(([k, n]) => `${k} x${n}`).join(', ')
117}
118
119// Warnings for one conversation (row or detail). Each: {level, text}.
120export function warnings(s, cfg) {
121 const t = cfg.thresholds || {}
122 const out = []
123 const subs = s.subs || []
124 const noModel = subs.filter((x) => !x.workflow && !x.explicit_model)
125 if (noModel.length) {
126 out.push({ level: 'warn', text: `${noModel.length} subagent(s) without explicit model (inherit ${short(s.model)}): ${noModel.slice(0, 3).map((x) => x.desc || x.type).join(', ')}` })
127 }
128 const ro = (cfg.readonly_agent_types || [])
129 const exempt = (cfg.readonly_exempt_types || [])
130 const opusRo = subs.filter((x) => family(x.model) === 'opus' && !exempt.includes(x.type) && (ro.includes(x.type)
131 || (x.tools >= (t.readonly_opus_min_calls ?? 5) && x.readonly / Math.max(1, x.tools) >= (t.readonly_opus_share ?? 0.8))))
132 if (opusRo.length) out.push({ level: 'warn', text: `Opus doing read-only search in ${opusRo.length} subagent(s): ${opusRo.slice(0, 3).map((x) => x.desc || x.type).join(', ')} (use Haiku)` })
133 if (family(s.model) === 'opus' && s.tools >= 20 && s.readonly / Math.max(1, s.tools) >= (t.readonly_opus_share ?? 0.8)) {
134 out.push({ level: 'warn', text: `Opus main thread: ${s.readonly}/${s.tools} tool calls are read-only search (delegate to explore-fast on Haiku)` })
135 }
136 if ((s.ctx || 0) > (t.handoff_tokens ?? 250000) && !s.resume_md) {
137 out.push({ level: 'bad', text: `context ${fmtTokens(s.ctx)} > ${fmtTokens(t.handoff_tokens ?? 250000)} and no handoff/RESUME.md` })
138 }
139 const loops = [loopText(s.loops), ...subs.map((x) => loopText(x.loops))].filter(Boolean)
140 if (loops.length) out.push({ level: 'warn', text: `timer/loop tools used: ${loops.join('; ')} (V4.7: no timer wake-ups)` })
141 return out
142}
143
144export function isGuard(text, cfg) {
145 return new RegExp(cfg.guard_match || 'denied|refused', 'i').test(text || '')
146}
147
148export function waitingOnJulian(s) {
149 return Boolean(s.ask_open || (s.needs_action && String(s.needs_action).trim()))
150}
151
152// ---- Global dashboard -------------------------------------------------------------------------------------------
153
154export function dashboard(scan, cfg, nowMs) {
155 const today = localDay(nowMs)
156 const rows = []
157 const byModel = {}
158 const byProject = {}
159 for (const s of scan.sessions || []) {
160 if (s.archived && !s.live) continue
161 const dayMaps = [(s.days || {})[today], ...(s.subs || []).map((x) => (x.days || {})[today])]
162 const billToday = billByModel(dayMaps, cfg)
163 const costToday = sumCost(billToday)
164 for (const [m, b] of Object.entries(billToday)) {
165 const o = byModel[m] || (byModel[m] = { tokens: 0, cost: 0, calls: 0 })
166 o.tokens += b.tokens; o.cost += b.cost; o.calls += b.calls
167 }
168 const repo = repoOf(s)
169 const p = byProject[repo] || (byProject[repo] = { cost: 0, tokens: 0, sessions: 0 })
170 p.cost += costToday; p.tokens += Object.values(billToday).reduce((a, b) => a + b.tokens, 0); p.sessions += 1
171 const w = warnings(s, cfg)
172 const err = s.last_error && s.last_error[2]
173 rows.push({
174 cli: s.cli, sid: s.sid, title: titleOf(s), repo, branch: s.branch || '', model: s.app_model || s.model,
175 effort: s.app_effort || s.effort || '?', ctx: s.ctx, ctxPct: ctxPct(s.ctx, cfg), ctxLevel: ctxLevel(s.ctx, cfg),
176 costToday, running: (s.subs || []).filter((x) => x.status === 'running').length, subs: (s.subs || []).length,
177 pr: s.pr ? { number: s.pr.number, url: s.pr.url, state: (s.ci || {}).state || '?', ci: (s.ci || {}).ci || '?' } : null,
178 problem: w.length ? w[0].text : err || '', problemLevel: w.length ? w[0].level : err ? 'warn' : 'ok',
179 waiting: waitingOnJulian(s), live: Boolean(s.live), last: s.last_activity,
180 })
181 }
182 const totalCalls = Object.values(byModel).reduce((a, b) => a + b.calls, 0)
183 const fam = {}
184 for (const [m, b] of Object.entries(byModel)) {
185 const f = family(m)
186 const o = fam[f] || (fam[f] = { calls: 0, cost: 0, tokens: 0 })
187 o.calls += b.calls; o.cost += b.cost; o.tokens += b.tokens
188 }
189 const targets = cfg.routing_targets || {}
190 const share = ['opus', 'sonnet', 'haiku'].map((f) => {
191 const v = fam[f] || { calls: 0, cost: 0 }
192 const sh = totalCalls ? v.calls / totalCalls : 0
193 const tg = targets[f] || {}
194 const ok = (tg.max === undefined || sh <= tg.max) && (tg.min === undefined || sh >= tg.min)
195 return { family: f, share: sh, calls: v.calls, cost: v.cost, target: tg.max !== undefined ? `<= ${Math.round(tg.max * 100)}%` : tg.min !== undefined ? `>= ${Math.round(tg.min * 100)}%` : '-', ok }
196 })
197 rows.sort((a, b) => (b.live - a.live) || (b.last - a.last))
198 return {
199 today, rows, byModel, byProject, share, totalCost: Object.values(byModel).reduce((a, b) => a + b.cost, 0), totalCalls,
200 }
201}
202
203// ---- One conversation ---------------------------------------------------------------------------------------------
204
205export function conversation(d, cfg, nowMs) {
206 const subs = d.subs || []
207 const mainBill = billByModel([d.models], cfg)
208 const node = (x) => {
209 const bill = billByModel([x.models], cfg)
210 const arch = isArchitect(x, cfg)
211 return {
212 id: x.id, type: x.type, model: x.model, explicit: x.explicit_model || null, effort: x.effort || '?', status: x.status,
213 tokens: Object.values(bill).reduce((a, b) => a + b.tokens, 0), cost: sumCost(bill),
214 task: x.desc || x.prompt || '', architect: arch ? architectReason(`${x.desc || ''} ${x.prompt || ''}`, cfg) : null,
215 phase: x.phase || null, bg: x.bg, ts: x.ts,
216 }
217 }
218 const agents = subs.filter((x) => !x.workflow).map(node)
219 const wfIds = [...new Set(subs.filter((x) => x.workflow).map((x) => x.workflow))]
220 const calls = d.calls || []
221 const workflows = wfIds.map((id) => {
222 const call = calls.find((c) => c.tool === 'Workflow' && c.runId === id)
223 const nodes = subs.filter((x) => x.workflow === id).map(node)
224 return { id, name: call ? call.desc : id, nodes, cost: nodes.reduce((a, b) => a + b.cost, 0),
225 tokens: nodes.reduce((a, b) => a + b.tokens, 0), running: nodes.filter((n) => n.status === 'running').length }
226 })
227 const allBill = billByModel([d.models, ...subs.map((x) => x.models)], cfg)
228 // cost per turn: the main thread's usage in the turn plus the subagents started during it
229 const turns = (d.turns || []).map((t, i, arr) => {
230 const next = arr[i + 1] ? arr[i + 1].ts : '9999'
231 const inTurn = subs.filter((x) => x.ts && x.ts >= t.ts && x.ts < next)
232 const own = sumCost(billByModel([t.models], cfg))
233 const sub = sumCost(billByModel(inTurn.map((x) => x.models), cfg))
234 return { ts: t.ts, kind: t.kind || 'human', text: t.text, cost: own + sub, own, sub, agents: inTurn.length }
235 })
236 const guards = [
237 ...(d.errors || []).filter((e) => isGuard(e[2], cfg)).map((e) => ({ ts: e[0], text: e[2], src: 'tool' })),
238 ...(d.guards || []).map((g) => ({ ts: g[0], text: `${g[1]}: ${g[2]}`, src: 'cc-control' })),
239 ].sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
240 return {
241 title: titleOf(d), repo: repoOf(d), branch: d.branch || '',
242 main: {
243 model: d.app_model || d.model, effort: d.app_effort || d.effort || '?', ctx: d.ctx, ctxPct: ctxPct(d.ctx, cfg),
244 ctxLevel: ctxLevel(d.ctx, cfg), window: cfg.context_window || 400000, cost: sumCost(allBill), ownCost: sumCost(mainBill),
245 live: Boolean(d.live),
246 },
247 agents, workflows, bill: allBill, total: sumCost(allBill), turns: turns.slice(-(cfg.turn_count || 10)),
248 guards: guards.slice(-12), events: compactEvents(d.events || []).slice(-(cfg.event_count || 15)), warnings: warnings(d, cfg),
249 pr: d.pr ? { ...d.pr, ...(d.ci || {}) } : null, waiting: waitingOnJulian(d),
250 }
251}
252
253// Session log: mcp__server__tool -> tool (server); runs of the same event collapse to one row "×n".
254export function compactEvents(events) {
255 const out = []
256 for (const e of events) {
257 const text = String(e[2]).replace(/\bmcp__([^_ ]+(?:_[^_ ]+)*)__([\w-]+)/, (m, srv, tool) => `${tool} (${srv.replace(/^ccd_/, '')})`)
258 const last = out[out.length - 1]
259 if (last && last[1] === e[1] && last[3] === text) { last[4] += 1; last[0] = e[0]; last[2] = `${text} ×${last[4]}`; continue }
260 out.push([e[0], e[1], text, text, 1])
261 }
262 return out.map((e) => [e[0], e[1], e[2]])
263}
264
265// Pick a conversation from the last scan by id prefix or title fragment (most recent first).
266export function pickSession(sessions, query) {
267 const q = String(query || '').trim().toLowerCase()
268 if (!q) return null
269 return (sessions || []).find((s) => String(s.cli).startsWith(q) || String(s.sid || '').replace(/^local_/, '').startsWith(q))
270 || (sessions || []).find((s) => titleOf(s).toLowerCase().includes(q)) || null
271}
272
273export function scanArgs(cfg, opts) {
274 const a = [opts.script, '--cache', opts.cache, '--hours', String((cfg.thresholds || {}).recent_hours ?? 36),
275 '--ci-ttl', String(cfg.ci_ttl_s ?? 600), '--live-s', String((cfg.thresholds || {}).live_s ?? 180)]
276 if (opts.detail) a.push('--detail', opts.detail)
277 if (cfg.include_cli) a.push('--cli')
278 return a
279}
280hooks/svg.mjs 337 lines1// factory - SVG drawings for the desktop pane (pure: model in, markup out). Codex agent-tree look: dark panel,
2// monospace, dashed boxes, arrows from the main thread to each agent, bars for effort, context and cost.
3// Colours come from cfg.svg (factory.config.json); nothing here is hard-coded but the geometry.
4import { fmtTokens, fmtUsd, hhmm, short, family } from './logic.mjs'
5
6const W = 760
7const CH = 6.6 // width of one 11px monospace character
8const MAX = 120000 // the Svg source cap is 131,072 characters
9
10export function esc(s) {
11 return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
12}
13
14export function wrap(s, n, max) {
15 const out = []
16 let cur = ''
17 for (const w of String(s ?? '').split(/\s+/)) {
18 if ((cur + ' ' + w).trim().length > n && cur) { out.push(cur); cur = w } else cur = (cur + ' ' + w).trim()
19 }
20 if (cur) out.push(cur)
21 if (out.length > max) { out.length = max; out[max - 1] = clip(out[max - 1] + ' …', n) }
22 return out.map((x) => clip(x, n))
23}
24
25export function clip(s, n) {
26 s = String(s ?? '')
27 return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + '…'
28}
29
30function pal(cfg) {
31 const d = {
32 bg: '#0d1117', panel: '#11161d', border: '#30363d', text: '#e6edf3', dim: '#8b949e', faint: '#21262d',
33 opus: '#f0883e', sonnet: '#58a6ff', haiku: '#7ee787', fable: '#d2a8ff', other: '#8b949e', architect: '#bc8cff',
34 running: '#58a6ff', done: '#7ee787', failed: '#ff7b72', stopped: '#8b949e', ok: '#7ee787', warn: '#e3b341', bad: '#ff7b72',
35 }
36 return { ...d, ...((cfg && cfg.svg && cfg.svg.colors) || {}) }
37}
38
39const EFFORT = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }
40
41class Canvas {
42 constructor(cfg) { this.c = pal(cfg); this.out = [] }
43 text(x, y, s, o = {}) {
44 const a = [`x="${x}"`, `y="${y}"`, `fill="${o.color || this.c.text}"`]
45 if (o.size) a.push(`font-size="${o.size}"`)
46 if (o.bold) a.push('font-weight="700"')
47 if (o.anchor) a.push(`text-anchor="${o.anchor}"`)
48 const t = o.tip ? `<title>${esc(o.tip)}</title>` : ''
49 this.out.push(`<text ${a.join(' ')}>${t}${esc(s)}</text>`)
50 }
51 // several coloured runs on one line: one <text>, the browser advances the tspans
52 runs(x, y, parts, o = {}) {
53 const sp = parts.filter((p) => p && p.t).map((p) => {
54 const a = [`fill="${p.c || this.c.text}"`]
55 if (p.b) a.push('font-weight="700"')
56 return `<tspan ${a.join(' ')}>${p.tip ? `<title>${esc(p.tip)}</title>` : ''}${esc(p.t)}</tspan>`
57 })
58 this.out.push(`<text x="${x}" y="${y}" xml:space="preserve"${o.size ? ` font-size="${o.size}"` : ''}${o.anchor ? ` text-anchor="${o.anchor}"` : ''}>${sp.join('')}</text>`)
59 }
60 box(x, y, w, h, color, o = {}) {
61 const dash = o.solid ? '' : ' stroke-dasharray="4 3"'
62 this.out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="3" fill="${o.fill || this.c.panel}" stroke="${color}" stroke-width="${o.sw || 1.2}"${dash}/>`)
63 if (o.label) {
64 const lw = o.label.length * CH + 12
65 this.out.push(`<rect x="${x + 10}" y="${y - 7}" width="${lw}" height="13" fill="${this.c.bg}"/>`)
66 this.text(x + 16, y + 3, o.label, { color, bold: true })
67 }
68 }
69 bar(x, y, w, frac, color, o = {}) {
70 const f = Math.max(0, Math.min(1, frac || 0))
71 this.out.push(`<rect x="${x}" y="${y}" width="${w}" height="${o.h || 7}" rx="1" fill="${this.c.faint}"/>`)
72 if (f > 0) this.out.push(`<rect x="${x}" y="${y}" width="${Math.max(2, w * f)}" height="${o.h || 7}" rx="1" fill="${color}"/>`)
73 if (o.mark !== undefined) {
74 const mx = x + w * o.mark
75 this.out.push(`<line x1="${mx}" y1="${y - 3}" x2="${mx}" y2="${y + (o.h || 7) + 3}" stroke="${this.c.text}" stroke-width="1.5"/>`)
76 }
77 }
78 pips(x, y, n, total, color) {
79 for (let i = 0; i < total; i++) {
80 this.out.push(`<rect x="${x + i * 7}" y="${y}" width="5" height="9" fill="${i < n ? color : this.c.faint}"/>`)
81 }
82 }
83 arrow(x1, y1, x2, y2, color) {
84 this.out.push(`<path d="M${x1},${y1} L${x2 - 6},${y2}" fill="none" stroke="${color}" stroke-width="1.2" stroke-dasharray="3 3"/>`)
85 this.out.push(`<path d="M${x2 - 6},${y2 - 4} L${x2},${y2} L${x2 - 6},${y2 + 4} Z" fill="${color}"/>`)
86 this.out.push(`<circle cx="${x1}" cy="${y1}" r="2.5" fill="${color}"/>`)
87 }
88 line(x1, y1, x2, y2, color) {
89 this.out.push(`<path d="M${x1},${y1} L${x2},${y2}" fill="none" stroke="${color}" stroke-width="1.2" stroke-dasharray="3 3"/>`)
90 }
91 dot(x, y, r, color) { this.out.push(`<circle cx="${x}" cy="${y}" r="${r}" fill="${color}"/>`) }
92 done(h) {
93 const body = this.out.join('')
94 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}" font-family="ui-monospace,SFMono-Regular,Consolas,Menlo,monospace" font-size="11">`
95 + `<rect width="${W}" height="${h}" rx="8" fill="${this.c.bg}"/>${body}</svg>`
96 }
97}
98
99const STATUS_ICON = { running: '◐ running', done: '✓ done', failed: '✗ failed', stopped: '◌ stopped' }
100
101function famColor(c, model) { return c[family(model)] || c.other }
102
103// ---- one conversation: agent tree ----------------------------------------------------------------------------
104
105export function treeSvg(m, cfg, at) {
106 const k = new Canvas(cfg)
107 const c = k.c
108 const L = 16, LW = 228, MX = 268, MW = 226, RX = 520, RW = 224
109 let y = 26
110 // header and legend
111 k.runs(L, y, [{ t: 'FACTORY', b: true }, { t: ' · ', c: c.dim }, { t: clip(m.title, 52), b: true, tip: m.title },
112 { t: ' ' + hhmm(at), c: c.dim }])
113 y += 18
114 k.runs(L, y, [{ t: clip(`${m.repo} · ${m.branch}`, 70), c: c.dim }])
115 y += 18
116 const fams = [...new Set([m.main.model, ...m.agents.map((a) => a.model), ...m.workflows.flatMap((w) => w.nodes.map((n) => n.model))].map(family))]
117 let lx = L
118 for (const f of fams) { k.out.push(`<rect x="${lx}" y="${y - 8}" width="8" height="8" fill="${c[f] || c.other}"/>`); k.text(lx + 12, y, f, { color: c.dim }); lx += 20 + f.length * CH + 10 }
119 k.out.push(`<rect x="${lx}" y="${y - 8}" width="8" height="8" fill="${c.architect}"/>`); k.text(lx + 12, y, 'architect', { color: c.dim })
120 y += 22
121
122 // left: main box
123 const top = y
124 const mc = famColor(c, m.main.model)
125 k.box(L, top, LW, 118, mc, { label: `${short(m.main.model)} · main` })
126 let ly = top + 22
127 k.runs(L + 12, ly, [{ t: 'effort ', c: c.dim }]); k.pips(L + 12 + 7 * CH, ly - 8, EFFORT[m.main.effort] || 0, 4, mc); k.text(L + 12 + 7 * CH + 32, ly, m.main.effort, { color: mc })
128 ly += 18
129 k.text(L + 12, ly, `context ${fmtTokens(m.main.ctx)} / ${fmtTokens(m.main.window)}`, { color: c[m.main.ctxLevel] })
130 k.bar(L + 12, ly + 5, LW - 24, m.main.ctx / m.main.window, c[m.main.ctxLevel], { mark: ((cfg.thresholds || {}).handoff_tokens || 250000) / m.main.window })
131 ly += 26
132 k.runs(L + 12, ly, [{ t: 'cost so far ', c: c.dim }, { t: fmtUsd(m.main.cost), b: true }, { t: ' est.', c: c.dim }])
133 ly += 16
134 k.runs(L + 12, ly, [{ t: m.pr ? `PR #${m.pr.number} ${m.pr.state || ''}` : 'no PR yet', c: c.dim }, m.pr ? { t: ` CI ${m.pr.ci || '?'}`, c: m.pr.ci === 'failed' ? c.bad : m.pr.ci === 'success' ? c.ok : c.dim } : null])
135 ly += 16
136 k.text(L + 12, ly, m.main.live ? '◐ running' : '○ idle', { color: m.main.live ? c.running : c.dim })
137 if (m.waiting) k.text(L + LW - 12, ly, 'waiting on Julian', { color: c.warn, anchor: 'end', bold: true })
138
139 // middle: agents and workflows
140 const MAXN = (cfg.svg && cfg.svg.max_boxes) || 10
141 const nodes = [...m.agents.map((a) => ({ kind: 'agent', a })), ...m.workflows.map((w) => ({ kind: 'wf', w }))]
142 const shown = nodes.slice(-MAXN)
143 let my = top
144 const BH = 76
145 const BUS = L + LW + 20
146 for (const n of shown) {
147 if (n.kind === 'agent') {
148 const a = n.a
149 const col = a.architect ? c.architect : famColor(c, a.model)
150 k.box(MX, my, MW, BH - 8, col, { label: clip(a.type, 22) })
151 k.runs(MX + 12, my + 20, [{ t: clip(short(a.model), 14), c: famColor(c, a.model) }, a.explicit ? null : { t: ' no model!', c: c.warn }])
152 k.text(MX + 12, my + 35, 'effort', { color: c.dim }); k.pips(MX + 12 + 7 * CH, my + 27, EFFORT[a.effort] || 0, 4, col)
153 k.text(MX + MW - 12, my + 35, `${fmtTokens(a.tokens)} · ${fmtUsd(a.cost)}`, { color: c.dim, anchor: 'end' })
154 k.text(MX + 12, my + 50, clip(a.architect ? `◆ ${a.architect} · ${a.task}` : a.task, 31), { color: a.architect ? c.architect : c.text, tip: a.task })
155 k.text(MX + 12, my + 63, STATUS_ICON[a.status] || a.status, { color: c[a.status] || c.dim })
156 } else {
157 const w = n.w
158 k.box(MX, my, MW, BH - 8, c.dim, { label: clip(`workflow ${w.name}`, 26) })
159 k.text(MX + 12, my + 20, `${w.nodes.length} agents · ${w.running} running`, { color: c.text })
160 k.text(MX + MW - 12, my + 35, `${fmtTokens(w.tokens)} · ${fmtUsd(w.cost)}`, { color: c.dim, anchor: 'end' })
161 w.nodes.slice(0, 28).forEach((x, i) => k.dot(MX + 16 + (i % 14) * 14, my + 46 + Math.floor(i / 14) * 12, 4, x.status === 'running' ? c.running : x.status === 'failed' ? c.bad : famColor(c, x.model)))
162 }
163 k.arrow(BUS, my + 22, MX, my + 22, n.kind === 'agent' && n.a.architect ? c.architect : c.dim)
164 my += BH
165 }
166 if (shown.length) { k.line(L + LW, top + 30, BUS, top + 30, c.dim); k.line(BUS, top + 30, BUS, my - BH + 22, c.dim) }
167 if (!shown.length) { k.box(MX, my, MW, 40, c.border); k.text(MX + 12, my + 24, 'no subagents yet', { color: c.dim }); my += 48 }
168 if (nodes.length > shown.length) { k.text(MX + 12, my + 4, `+ ${nodes.length - shown.length} earlier agents`, { color: c.dim }); my += 14 }
169
170 // right: bill, approvals, warnings
171 let ry = top
172 const bill = Object.entries(m.bill).sort((a, b) => b[1].cost - a[1].cost)
173 const bh = 34 + bill.length * 26 + 22 + 34
174 k.box(RX, ry, RW, bh, c.architect, { label: 'bill · estimate' })
175 let by = ry + 20
176 const maxCost = Math.max(0.0001, ...bill.map(([, b]) => b.cost))
177 for (const [model, b] of bill) {
178 k.runs(RX + 12, by, [{ t: clip(short(model), 12), c: famColor(c, model) }])
179 k.text(RX + RW - 12, by, fmtUsd(b.cost), { anchor: 'end', bold: true })
180 k.bar(RX + 12, by + 5, RW - 24, b.cost / maxCost, famColor(c, model))
181 by += 26
182 }
183 k.runs(RX + 12, by, [{ t: 'total ', c: c.dim }, { t: fmtUsd(m.total), b: true }])
184 by += 14
185 k.text(RX + 12, by + 2, 'per turn', { color: c.dim })
186 const turns = m.turns
187 const tmax = Math.max(0.0001, ...turns.map((t) => t.cost))
188 const tw = Math.max(4, Math.floor((RW - 24) / Math.max(1, turns.length)) - 3)
189 turns.forEach((t, i) => {
190 const hh = Math.max(1, 24 * t.cost / tmax)
191 k.out.push(`<rect x="${RX + 12 + i * (tw + 3)}" y="${by + 32 - hh}" width="${tw}" height="${hh}" fill="${t.agents ? c.architect : c.sonnet}"><title>${esc(`${hhmm(t.ts)} ${fmtUsd(t.cost)} ${t.kind}: ${t.text}`)}</title></rect>`)
192 })
193 ry += bh + 16
194
195 const guards = m.guards.slice(-5)
196 const gh = 26 + Math.max(1, guards.length) * 15
197 k.box(RX, ry, RW, gh, c.ok, { label: 'approvals · guards' })
198 if (!guards.length) k.text(RX + 12, ry + 22, '✓ nothing refused', { color: c.ok })
199 guards.forEach((g, i) => k.runs(RX + 12, ry + 22 + i * 15, [{ t: hhmm(g.ts).slice(0, 5) + ' ', c: c.dim }, { t: clip(g.text, 25), c: c.warn, tip: g.text }]))
200 ry += gh + 16
201
202 // left column under the main box: warnings, wrapped
203 let wy = top + 118 + 18
204 const warns = m.warnings.slice(0, 6)
205 const wl = warns.map((w) => ({ w, lines: wrap(w.text, 32, 3) }))
206 const wh = 24 + Math.max(1, wl.reduce((n, x) => n + x.lines.length, 0)) * 13 + wl.length * 5
207 k.box(L, wy, LW, wh, warns.length ? c.warn : c.ok, { label: `warnings · ${m.warnings.length}` })
208 if (!warns.length) k.text(L + 12, wy + 22, '✓ none', { color: c.ok })
209 let wyy = wy + 22
210 for (const x of wl) {
211 x.lines.forEach((ln, i) => k.text(L + 12, wyy + i * 13, (i ? ' ' : '• ') + ln, { color: c[x.w.level], tip: x.w.text }))
212 wyy += x.lines.length * 13 + 5
213 }
214 wy += wh + 16
215
216 // architect strip
217 y = Math.max(wy, my, ry) + 8
218 const arch = [...m.agents, ...m.workflows.flatMap((w) => w.nodes)].filter((a) => a.architect)
219 const moments = ((cfg.architect && cfg.architect.reasons) || []).map((r) => r.label)
220 k.box(L, y, W - 2 * L, 46, c.architect, { label: 'architect · on call' })
221 let ax = L + 12
222 for (const mo of moments) {
223 const hit = arch.some((a) => a.architect === mo)
224 k.text(ax, y + 22, `${hit ? '◆' : '◇'} ${mo}`, { color: hit ? c.architect : c.dim, bold: hit }); ax += (mo.length + 5) * CH
225 }
226 k.text(W - L - 12, y + 22, `calls ${arch.length} read ${fmtTokens(arch.reduce((s, a) => s + a.tokens, 0))}`, { color: c.text, anchor: 'end' })
227 const last = arch[arch.length - 1]
228 k.text(L + 12, y + 37, last ? clip(`last: » ${last.task}`, 104) : 'no architect call in this conversation', { color: c.dim })
229 y += 62
230
231 // session log
232 const ev = m.events
233 const lh = 24 + ev.length * 15
234 k.box(L, y, W - 2 * L, lh, c.border, { label: 'session log' })
235 const KIND = { prompt: c.text, human: c.text, agent: c.architect, notif: c.haiku, tool: c.sonnet, pr: c.opus, compact: c.warn, peer: c.fable }
236 ev.forEach((e, i) => k.runs(L + 12, y + 22 + i * 15, [{ t: hhmm(e[0]) + ' ', c: c.dim }, { t: clip(e[1], 7).padEnd(8), c: KIND[e[1]] || c.dim, b: true }, { t: clip(e[2], 88), tip: e[2] }]))
237 y += lh + 18
238
239 // status line
240 const run = m.agents.filter((a) => a.status === 'running').length + m.workflows.reduce((s, w) => s + w.running, 0)
241 const total = m.agents.length + m.workflows.reduce((s, w) => s + w.nodes.length, 0)
242 k.runs(L, y, [{ t: 'main ', c: c.dim }, { t: `[${short(m.main.model)} · ${m.main.effort}]`, c: mc }, { t: ' agents ', c: c.dim }, { t: `[${run}/${total}]`, c: c.running },
243 { t: ' architect ', c: c.dim }, { t: `[${arch.length} calls]`, c: c.architect }, { t: ' ctx ', c: c.dim }, { t: `[${m.main.ctxPct}%]`, c: c[m.main.ctxLevel] }])
244 const svg = k.done(y + 16)
245 return svg.length <= MAX ? svg : treeSvg({ ...m, events: m.events.slice(-5), agents: m.agents.slice(-4), workflows: m.workflows.slice(-1) }, cfg, at)
246}
247
248// ---- dashboard -------------------------------------------------------------------------------------------------
249
250export function dashSvg(g, cfg, at, label, opts = {}) {
251 const k = new Canvas(cfg)
252 const c = k.c
253 const L = 16
254 let y = 26
255 k.runs(L, y, [{ t: 'FACTORY', b: true }, { t: ' · all conversations', c: c.dim }, { t: ' ' + g.today + ' ' + hhmm(at), c: c.dim }])
256 y += 16
257 // KPI tiles
258 const live = g.rows.filter((r) => r.live).length
259 const waiting = g.rows.filter((r) => r.waiting).length
260 const tiles = [['conversations', String(g.rows.length), c.text], ['live now', String(live), c.running], ['today', fmtUsd(g.totalCost), c.opus],
261 ['calls today', String(g.totalCalls), c.text], ['wait on Julian', String(waiting), waiting ? c.warn : c.ok]]
262 const tw = (W - 2 * L - 4 * 10) / 5
263 tiles.forEach(([lab, val, col], i) => {
264 const x = L + i * (tw + 10)
265 k.box(x, y + 8, tw, 46, c.border, { solid: true })
266 k.text(x + 10, y + 26, lab, { color: c.dim })
267 k.text(x + 10, y + 46, val, { color: col, size: 17, bold: true })
268 })
269 y += 76
270 // routing share: one stacked bar + target marks
271 k.box(L, y, W - 2 * L, 70, c.border, { label: `model share today (${label}) vs V4.8 targets` })
272 const bx = L + 12, bw = W - 2 * L - 24
273 let cx = bx
274 for (const s of g.share) {
275 const w = bw * s.share
276 if (w > 0) k.out.push(`<rect x="${cx}" y="${y + 16}" width="${w}" height="14" fill="${c[s.family]}"><title>${esc(`${s.family} ${Math.round(s.share * 100)}%`)}</title></rect>`)
277 cx += w
278 }
279 g.share.forEach((s, i) => {
280 const x = L + 12 + i * ((W - 2 * L) / 3)
281 k.runs(x, y + 48, [{ t: '■ ', c: c[s.family] }, { t: `${s.family} ${Math.round(s.share * 100)}%`, b: true }, { t: ` target ${s.target} `, c: c.dim }, { t: s.ok ? '✓' : '✗', c: s.ok ? c.ok : c.bad, b: true }])
282 k.text(x + 12, y + 62, `${s.calls} calls · ${fmtUsd(s.cost)}`, { color: c.dim })
283 })
284 y += 86
285 // per model and per project bars, side by side
286 const half = (W - 2 * L - 16) / 2
287 const models = Object.entries(g.byModel).sort((a, b) => b[1].cost - a[1].cost)
288 const projects = Object.entries(g.byProject).filter(([, b]) => b.cost > 0).sort((a, b) => b[1].cost - a[1].cost).slice(0, 8)
289 const h2 = 26 + Math.max(models.length, projects.length) * 24
290 k.box(L, y, half, h2, c.border, { label: 'today per model' })
291 const mm = Math.max(0.0001, ...models.map(([, b]) => b.cost))
292 models.forEach(([m, b], i) => {
293 const yy = y + 20 + i * 24
294 k.text(L + 12, yy, clip(short(m), 12), { color: famColor(c, m) }); k.text(L + half - 12, yy, `${fmtUsd(b.cost)} · ${fmtTokens(b.tokens)}`, { anchor: 'end' })
295 k.bar(L + 12, yy + 5, half - 24, b.cost / mm, famColor(c, m), { h: 5 })
296 })
297 const PX = L + half + 16
298 k.box(PX, y, half, h2, c.border, { label: 'today per project' })
299 const pm = Math.max(0.0001, ...projects.map(([, b]) => b.cost))
300 projects.forEach(([p, b], i) => {
301 const yy = y + 20 + i * 24
302 k.text(PX + 12, yy, clip(p, 26), { tip: p }); k.text(PX + half - 12, yy, fmtUsd(b.cost), { anchor: 'end', bold: true })
303 k.bar(PX + 12, yy + 5, half - 24, b.cost / pm, c.sonnet, { h: 5 })
304 })
305 y += h2 + 18
306 if (opts.rows !== false) {
307 // conversation rows, two lines each
308 const rows = g.rows
309 const rh = 38
310 k.box(L, y, W - 2 * L, 24 + rows.length * rh, c.border, { label: `conversations · ${rows.length}` })
311 rows.forEach((r, i) => {
312 const yy = y + 22 + i * rh
313 if (i % 2) k.out.push(`<rect x="${L + 2}" y="${yy - 13}" width="${W - 2 * L - 4}" height="${rh}" fill="${c.faint}" opacity="0.45"/>`)
314 k.dot(L + 14, yy - 4, 4, r.live ? c.running : c.border)
315 k.runs(L + 26, yy, [{ t: clip(r.title, 40).padEnd(41), b: r.live, tip: r.title }, { t: clip(`${r.repo} · ${r.branch.replace(/^claude\//, '')}`, 36), c: c.dim, tip: `${r.repo} · ${r.branch}` }])
316 k.text(W - L - 12, yy, `${short(r.model)} · ${r.effort}`, { color: famColor(c, r.model), anchor: 'end' })
317 const y2 = yy + 15
318 k.text(L + 26, y2, 'ctx', { color: c.dim }); k.bar(L + 26 + 4 * CH, y2 - 7, 60, r.ctxPct / 100, c[r.ctxLevel]); k.text(L + 26 + 4 * CH + 66, y2, `${r.ctxPct}%`, { color: c[r.ctxLevel] })
319 k.runs(L + 160, y2, [{ t: fmtUsd(r.costToday).padStart(7), b: true }, { t: ' ' }, { t: `◐${r.running}/${r.subs}`.padEnd(8), c: r.running ? c.running : c.dim }])
320 const pr = r.pr ? `#${r.pr.number} ${r.pr.state}` : '-'
321 k.runs(L + 270, y2, [{ t: clip(pr, 12).padEnd(13), c: c.dim }, r.pr ? { t: clip(r.pr.ci, 8).padEnd(9), c: r.pr.ci === 'failed' ? c.bad : r.pr.ci === 'success' ? c.ok : c.dim } : { t: ''.padEnd(9) },
322 { t: r.waiting ? 'JULIAN ' : '', c: c.warn, b: true }, { t: clip(r.problem || '', r.waiting ? 34 : 41), c: c[r.problemLevel] || c.dim, tip: r.problem }])
323 })
324 y += 24 + rows.length * rh + 18
325 }
326 k.text(L, y, '$ = estimate from tokens × config rates. Click a conversation below to open its agent tree.', { color: c.dim })
327 return k.done(y + 14)
328}
329
330export function treeAlt(m) {
331 return `Agent tree of ${m.title}: main ${short(m.main.model)} ${m.main.effort}, context ${m.main.ctxPct}%, ${m.agents.length} subagents, ${m.workflows.length} workflows, cost ${fmtUsd(m.total)} estimate, ${m.warnings.length} warnings.`
332}
333
334export function dashAlt(g) {
335 return `${g.rows.length} conversations, ${fmtUsd(g.totalCost)} today (estimate); ` + g.share.map((s) => `${s.family} ${Math.round(s.share * 100)}%`).join(', ')
336}
337