SLOPSHOPPER

factory

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

newpanecommandprocesstimer
v0.3.0MITupdated 2026-10-16julian-passebecq/claude-mod/mods/factory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory
│ ┃ Factory ✕ › fix the failing auth test and add an audit log call │ ┃ scanner: JSON Parse error: Unexpected │ ┃ identifier "dev" ⏺ Read(src/auth.ts) │ ┃ Scanning transcripts… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /factory │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Factory
scanner: JSON Parse error: Unexpected identifier "dev" Scanning transcripts…
README

claude-mod

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).

The mods

ModCommandWhat 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.

Catalog

What was kept from the earlier mods when this repo was set up (V6, Oct 2026). Only the keep rows are in this repo.

ModVerdictWhy
factorykeepThe 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.
controlkeepThe "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.
coordinatormerge into factoryLinked 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 bandSmall and correct: context size against the hand-off threshold, read-only. Not written yet.
cc-control guardsdropThey refused and rewrote tool calls (send caps, spawn caps, rewrites). Blocking hooks are out.
cinew (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.
scenariosnew (Oct 2026)Which model, effort and unit each role uses, with the doc that says so, instead of rules text that drifts.
guidenew (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, nightdropWrong numbers for new model ids, superseded by factory, or built on timer wake-ups.
V4 pages and watcherdropDead since early Oct, replaced by factory and the app's own agent view.

Install

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.

ci

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: --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.
  • Status check: token presence only (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: 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.
  • Needs Python 3 on PATH; gh and glab are optional.

Rules for every mod here

  • Show and warn, never block: no deny, no rewriting of tool calls or prompts.
  • Read-only on your files; each mod writes only its own cache and output folder (ci also writes its routing file, on /ci route and /ci clear).
  • No network except gh / glab for PR state and the GitLab API for runner status (ci).
  • Configurable legends, labels, colors and thresholds.
  • Tests next to the code.

Tests

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.

License

MIT

Source 3 files
hooks/register.mjs 349 lines
1// 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}
349
hooks/logic.mjs 280 lines
1// 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}
280
hooks/svg.mjs 337 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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