Helper Desk: a purple task progress bar above the prompt that moves as each to-do item is done, and a side pane with one live card per subagent

Current publication inventory — 2026-10-24: 135 bilingual posts across10 series (7 Tutorials /96 articles,3 Thoughts /39 essays),147 HTML files (146 enumerated), 466 images,135 feed items and146 sitemap URLs. AI-Core Functional Workflows adds twelve lessons paired with Anirach/ai-core-functional-tutorial,108 offline tests and twelve exercises. Earlier dated measurements below retain their historical context.
The personal site of Dr. Anirach Mingkhwan — Associate Professor at King Mongkut's University of Technology North Bangkok (KMUTNB), PhD (Liverpool John Moores University, UK), researcher in Agentic AI, computer networks, and big data analytics.
🌐 Live: anirach.com
A hand-written static site — seven top-level pages, four per-book detail pages, and 135 self-contained blog posts catalogued on two pages: Tutorials (/blog/, seven technical series) and Thoughts (/thoughts/, thirty-nine essays on living well and working well). No build system, no package manager, no dependencies, no JavaScript framework. Push to main and GitHub Pages publishes it.
| Pages | Home · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates |
| Posts | 135, in 10 series across two catalogs, every one bilingual Thai/English |
| Files | 147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded) |
| Stack | Plain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency |
| Hosting | GitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare |
| Build step | None |
| Tests | Four scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite |
| Page | What it holds |
|---|---|
index.html | Single-scroll consultant profile: hero, at-a-glance stats, six services, engagement approach, selected work, about and career, research, free resources, latest news with the featured book, contact |
blog/ | The Tutorials catalog — 96 cards in seven technical series, plus all 135 post files |
thoughts/ | The Thoughts catalog — 39 essay cards in three series, each linking ../blog/<slug>.html |
publications/ | The academic record: the Springer book Libraries in Transformation, 8 book chapters, and a selected-publications table |
books/ | Books & writing: four works — One Day of Light (the free last-lecture event book, EN/TH editions with free PDF downloads served from books/), the published novel Three Old Men: The Last Conversation, and two complete bilingual manuscripts — each with its own detail page |
projects/ | Live apps (each verified working before it ships) and research-code repositories |
news/ | Reverse-chronological timeline of publications, talks and appointments, plus a career timeline |
The posts never move. Every post is blog/<slug>.html, including the 39 essays that /thoughts/ catalogues — the 2026-09-08 split separated the two catalogs, not the files.
books/ was one "Books & Writing" page until 2026-08-23, when it split: academic content moved to the new publications/, and books/ became the fiction section with one detail page per book. On 2026-08-24 it gained a fourth work, the Last Lecture companion book One Day of Light, whose EN/TH PDFs are downloadable for free from books/.
script.js is gone — deleted 2026-08-26. The site loads no executable JavaScript at all, and check_site.py INV-38 fails the build on any <script> that is not application/ld+json (schema.org metadata, which the browser parses as data and never executes — 128 pages carry one). Each of the script's four jobs has a CSS replacement in style.css: the scroll-reveal and the nav scroll state are scroll-driven CSS animations (animation-timeline: view() / scroll(root)), the landing page's mobile menu is .nav__links:target, and anchor scrolling is native scroll-behavior: smooth. The other pages' mobile menu is a pure-CSS checkbox toggle — a second mechanism, also without JavaScript.
Both are static, zero-JavaScript and generated by scripts/reindex_blog.py — re-run it rather than hand-ordering cards. Each is a hero, a sticky chip strip, a "Start here" feature, a row of series tiles, then one numbered-row-card section per series. There is no filtering, sorting or search UI.
Tutorials — /blog/, hero reads 7 Series · 96 Articles
| Series | Posts | Navigation |
|---|---|---|
| ⧉ AI-Core Functional Workflows | 12 | generated12-chip strip · GitHub code, tests and exercises |
| 🛡️ Engineering AI-Core Systems | 10 | chip strip · generated |
| 💻 Hermes Desktop Hands-On | 7 | chip strip · generated |
| 🧭 AI Transformation for Organizations | 20 | grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated |
| ⚡ Hermes Agent in Practice | 10 | chip strip |
| 🤖 OpenClaw for Organizations | 13 | seven are a numbered chip-strip series; six stand alone with no nav |
| 🔧 DevOps & Vibe Coding | 24 | a single prev/next chain |
Thoughts — /thoughts/, hero reads 3 Series · 39 Essays
| Series | Essays | Navigation |
|---|---|---|
| 🌱 Wisdom for a Good Life | 20 | grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated |
| 🧭 Working Philosophies | 10 | chip strip, one working week Monday to Friday · generated |
| 🌅 Life Thought & Philosophy | 9 | chip strip, one day in three parts (Morning · Noon · Twilight) |
Five of the nine series are generated from a manifest plus per-post content sheets: python3 scripts/build_series.py --series <name>, with manifests in scripts/series/ (ai-core, ai-transformation, good-life, hermes-desktop, working). For those, the manifest and the sheets are the edit surface — never the emitted post.
Every post is bilingual. All 123 carry a pure-CSS Thai ⇄ English switch: a ไทย · English pill in the hero swaps between two complete tracks of the same article. Thai is the default, there is no JavaScript, and the page-level <html lang="th"> never changes — the English track is lang="en" inside it. The seven top-level pages and the four book detail pages are lang="en" chrome and wrap their Thai passages in <span lang="th">.
The Thoughts essays are written in a different register from the tutorials — literary Thai in the author's own voice, not code-switched technical prose. The brief, with ten rules and worked before/after pairs, is .claude/skills/blog-post/references/thai-essay-voice.md.
.
├── index.html # Landing page — the ONLY file that loads style.css
├── style.css # Landing-page styles; carries the canonical :root token block
├── _config.yml # Jekyll: keeps internal working docs out of the published site
├── CNAME # anirach.com
├── sitemap.xml # 134 URLs, generated · feed.xml — 123 items, generated · llms.txt · robots.txt
├── blog/ # index.html (the Tutorials catalog, 96 cards) + all 123 self-contained posts
├── thoughts/ # index.html (the Thoughts catalog — 39 essay cards linking ../blog/)
├── books/ # index.html + 4 per-book detail pages + 3 free PDFs, all self-contained
├── publications/ projects/ news/ # one self-contained index.html each
├── images/ # 431 files — 124 covers (800×800) + 8 book faces, 123 card thumbs (144×144),
│ # 130 share cards (1200×630), diagrams, series figures, posters, profile photo
├── scripts/ # 13 generators and sweeps (build_series, reindex_blog, gen_feed, gen_sitemap,
│ # make_cover, make_figure, bilingualize, check_visibility, …)
├── docs/ # design spec, implementation plan, OpenClaw runbook (not published)
└── .claude/skills/ # the four maintenance skills (not published)
The single most important fact before editing:
index.html ──▶ style.css (the ONLY consumer of it)
blog/index.html ──▶ its own <style> block, no JS
blog/<post>.html ──▶ its own <style> block, no JS × 123
thoughts/ books/ (index + 4 detail) publications/ projects/ news/ 404.html ──▶ same × 10
Every page embeds its complete stylesheet. There is no shared partial, template engine or token file, so a "global" change means editing N files by hand — and nothing tells you when file 23 of 135 got missed. That is what the linter is for. The compensating upside: blast radius is exactly one file.
No install, no build. Serve the repository root:
python3 -m http.server 8000 # → http://localhost:8000
Serve the root, not
blog/. Every blog page references images as../images/….
Known local-dev trap. The eight chip-strip series — 93 posts in all — link to each other with root-absolute, extensionless URLs (
/blog/openclaw-101). GitHub Pages resolves those;http.serverdoes not and returns 404. That is not a regression.
Run all four from the repo root before pushing. All must pass:
python3 .claude/skills/site-check/scripts/check_site.py # 62 cross-file integrity checks
python3 scripts/check_visibility.py --strict # search/AI-visibility, JSON-LD, metadata
python3 docs/openclaw/check-news-sync.py # news↔homepage sync, provenance, 4 counters
python3 .claude/skills/blog-post/assets/verify-wiring.py # blog post wiring
check_site.py runs 62 checks — 43 fail-severity, 17 warn, 2 info — and exits 0 when no fail-severity violation appears. Its baseline of pre-existing debt is empty since 2026-08-26 — the tree is clean (0 new, 0 known), so any violation it prints is new and any fail-severity one blocks the push. INV-25 fails the build if a baseline entry is ever added and goes stale, so the safety net cannot silently rot. INV-26 (added with the books split) ties every section-directory detail page to its own index.html: an orphan detail page, or an index card linking a file that does not exist, fails the build.
check-news-sync.py verifies the homepage strip mirrors the three newest news items, that every news item carries a <!-- source: … --> provenance comment, and that all four hand-typed counters match reality: news/index.html "7 updates", publications/index.html "8 chapters", and books/index.html "1 novel" and "2 complete" (it strips HTML comments first, so a commented-out card cannot satisfy a counter).
feed.xml and sitemap.xml are generated, not hand-written — scripts/gen_feed.py and scripts/gen_sitemap.py. INV-31 and INV-32 fail the build when either drifts from the catalogs. Run the sitemap in the commit after a content change, so the git lastmod dates exist.
git push origin main
That is the whole pipeline; GitHub Pages rebuilds in 1–3 minutes. _config.yml excludes docs/ and CLAUDE.md from the published output — they stay in the repository but are not served. Anything beginning with . or _ (including .claude/) is omitted by Jekyll automatically.
Cloudflare caches HTML for 10 minutes and assets for 4 hours. After a deploy, append
?cb=$RANDOMto check the real state before diagnosing a problem that is not there — and remember your own browser holds its copy for the same 10 minutes, so a stale tab needs a hard reload rather than a bug report.
Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:
Inter:wght@300..900 gets one variable file; the semicolon form Inter:wght@300;400;…;900 makes the API serve one static file per weight — 7 files and 331 KB for the latin subset against 1 file and 47 KB. Switching saved 189 KB per page, 56% of the font bytes. Sarabun keeps the semicolon form**: it has no variable version, a range request returns HTTP 400, and all five of its static weights are used.srcset. The row card renders its cover in a 72px box, so an 800×800 source was ~11× oversampled. /blog/ went from 3.63 MB to 0.42 MB, /thoughts/ from 2.11 MB to 0.38 MB.Both are documented with their measurements in the a11y-perf skill (R8b and R1b).
A post is not one file — it is a file plus a cover plus a share card plus a card plus counters plus its neighbours' nav links plus a feed item, a sitemap entry and an llms.txt line, in two language tracks, with no build step to catch a miss. The full recipe, including which of the four navigation patterns applies, is in .claude/skills/blog-post/SKILL.md. Start from .claude/skills/page-design/assets/post-template.html — the repo's single canonical template. For one of the five generated series, add a manifest row and run build_series.py instead; never hand-edit those posts.
Four skills in .claude/skills/ carry the verified detail:
| Skill | Covers |
|---|---|
| page-design | The house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by |
| blog-post | Adding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief |
| a11y-perf | Accessibility and performance rules with this site's real measured numbers |
| site-check | The linter, what every check means, and how to repair each failure |
Every number in those skills is load-bearing: a change that invalidates one must update it in the same commit. A confidently wrong measurement is worse than none, because it gets acted on.
News updates have their own runbook at docs/openclaw/latest-updates-runbook.md, written for an autonomous agent. Its gate enforces that every news item carries a verified source — no structural check can tell a true item from an invented one, so instead an item cannot exist without a stated source.
Open to research collaborations, speaking engagements, and academic partnerships.
No LICENSE file is present. Content and code are © Anirach Mingkhwan; all rights reserved by default.
Twelve bilingual lessons and the matching code are maintained together: Anirach/ai-core-functional-tutorial. The offline lab ZIP is downloads/ai-core-functional-lab.zip. The series manifest is scripts/series/ai-core-functional.json; article source links pin the reviewed companion commit. Default fixtures require no SDK install, API key or paid calls. Run the companion tests/advertised commands and the four website checks when revising. No additional reuse license is granted by the companion's public availability.
hooks/register.tsx 1417 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { BandPlan, Helper, HelperPhase, PlanStatus, Step, StepKind } from '../types'
4import { connectorName, splitTool } from './connectors'
5
6const NAME = 'helper-desk'
7const PANE = 'helpers'
8// The name a person reads; the plugin and its /helpers command keep their old names.
9const TITLE = 'Helper Desk'
10const HELPERS = { plugin: 'helper-desk', key: 'helpers' } as const
11const CLOCK = { plugin: 'helper-desk', key: 'clock' } as const
12const VIEW = { plugin: 'helper-desk', key: 'view' } as const
13const COST = { plugin: 'helper-desk', key: 'cost' } as const
14const OFF = { plugin: 'helper-desk', key: 'off' } as const
15const HIDDEN = { plugin: 'helper-desk', key: 'hidden' } as const
16const PLAN = { plugin: 'helper-desk', key: 'plan' } as const
17const SHIM = { plugin: 'helper-desk', key: 'shimmer' } as const
18
19const TICK_MS = 300
20const POLL_MS = 2000
21const SWITCH_MS = 3000
22const HISTORY = 3
23const MAX_STEPS = 200
24const TAG_W = 11
25// Columns the pet takes beside a card; the Svg is 48 by 36 CSS pixels.
26const PET_W = 8
27const PET_PX_W = 48
28const PET_PX_H = 36
29// A running pet changes frame every second tick: about every 600 ms.
30const FRAME_TICKS = 2
31
32const ACCENT = '#d97757'
33const GOOD = '#16a34a'
34const BAD = '#dc2626'
35const WARN = '#eab308'
36const INK = '#1c1917'
37// The task bar is a purple gradient, light at the start and deep at the head (after Johnny's Savvy Progress).
38const P_LIGHT = '#c4b5fd'
39const P_MID = '#8b5cf6'
40const P_DEEP = '#6d28d9'
41// Quarter circles read clearly on desktop fonts and in the terminal.
42const SPIN = ['◐', '◓', '◑', '◒']
43const PULSE = ['●∙∙', '∙●∙', '∙∙●', '∙●∙']
44
45const KIND_COLOR: Record<StepKind, string> = {
46 Command: '#ca8a04',
47 Connector: '#2563eb',
48 'File read': '#16a34a',
49 'File write': '#d97757',
50 Skill: '#9333ea',
51 Web: '#0891b2',
52 Subagent: '#db2777',
53 Tool: '#78716c',
54}
55
56// ---------- Pets: a pixel Claude crab, one body colour per helper ----------
57
58type Px = [x: number, y: number]
59
60// Eight body colours that are easy to tell apart and read on light and dark.
61const HUES: { name: string; color: string }[] = [
62 { name: 'red', color: '#dc2626' },
63 { name: 'orange', color: '#ea580c' },
64 { name: 'yellow', color: '#eab308' },
65 { name: 'green', color: '#16a34a' },
66 { name: 'teal', color: '#0d9488' },
67 { name: 'blue', color: '#2563eb' },
68 { name: 'purple', color: '#9333ea' },
69 { name: 'pink', color: '#db2777' },
70]
71const hueOf = (hue: number) => HUES[((hue % HUES.length) + HUES.length) % HUES.length]!
72
73// Eyes and outline are the same on every pet.
74const EYE = '#2b1d16'
75const OUTLINE = '#2b1d16'
76const FRAMES = 4
77
78const span = (y: number, from: number, to: number): Px[] => Array.from({ length: to - from + 1 }, (_, i) => [from + i, y] as Px)
79
80/**
81 * One frame of the crab on a 16 by 12 grid. Frame 0 stands; 1 hops up a pixel with
82 * its legs moved; 2 stands and blinks; 3 raises both claws.
83 */
84function crab(frame: number): { body: Px[]; eyes: Px[] } {
85 const dy = frame === 1 ? 0 : 1
86 const clawsUp = frame === 3
87 const body: Px[] = [
88 ...span(1, 3, 12),
89 ...span(2, 3, 12),
90 ...span(3, 3, 12),
91 ...(clawsUp ? span(4, 3, 12) : span(4, 1, 14)),
92 ...(clawsUp ? span(5, 3, 12) : span(5, 1, 14)),
93 ...span(6, 3, 12),
94 ...span(7, 3, 12),
95 ]
96 if (clawsUp) body.push([1, 2], [2, 2], [1, 3], [2, 3], [13, 2], [14, 2], [13, 3], [14, 3])
97 const legs = frame % 2 === 1 ? [3, 5, 10, 12] : [4, 6, 9, 11]
98 for (const x of legs) body.push([x, 8], [x, 9])
99 const eyes: Px[] = frame === 2 ? [[5, 4], [10, 4]] : [[5, 3], [5, 4], [10, 3], [10, 4]]
100 const move = (p: Px): Px => [p[0], p[1] + dy]
101 return { body: body.map(move), eyes: eyes.map(move) }
102}
103
104const petCache = new Map<string, string>()
105
106function petSvg(hue: number, frame: number) {
107 const key = `${hue % HUES.length}:${frame}`
108 const hit = petCache.get(key)
109 if (hit) return hit
110 const { body, eyes } = crab(frame)
111 const fill = hueOf(hue).color
112 const has = new Set(body.map(([x, y]) => `${x},${y}`))
113 // A one-pixel outline round the body, so every colour reads on light and dark.
114 const ring = new Set<string>()
115 for (const [x, y] of body) {
116 for (const [ax, ay] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] as Px[]) {
117 const k = `${ax},${ay}`
118 if (!has.has(k)) ring.add(k)
119 }
120 }
121 const rect = (x: number, y: number, c: string) => `<rect x="${x + 1}" y="${y + 1}" width="1" height="1" fill="${c}"/>`
122 const parts = [
123 ...[...ring].map(k => {
124 const [x, y] = k.split(',').map(Number)
125 return rect(x!, y!, OUTLINE)
126 }),
127 ...body.map(([x, y]) => rect(x, y, fill)),
128 ...eyes.map(([x, y]) => rect(x, y, EYE)),
129 ]
130 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 13" width="${PET_PX_W}" height="${PET_PX_H}" shape-rendering="crispEdges">${parts.join('')}</svg>`
131 petCache.set(key, svg)
132 return svg
133}
134
135// ---------- The desk itself; $.state carries a copy for the pane to draw ----------
136
137let helpers: Helper[] = []
138let seats = 0
139// The next body colour to hand out, in HUES order.
140let nextHue = 0
141let tickN = 0
142let home = ''
143let sessionId = ''
144let off = false
145// "helper-desk:hidden": true in switches.json closes the pane and stops it opening by itself.
146let hidden = false
147let ticker: { cancel: () => void } | null = null
148let poller: { cancel: () => void } | null = null
149let switcher: { cancel: () => void } | null = null
150const strangers = new Set<string>()
151
152function toolWords(tool: string, connector: string) {
153 const words = tool
154 .replace(/([a-z])([A-Z])/g, '$1 $2')
155 .split(/[-_\s]+/)
156 .filter(Boolean)
157 .map(w => w.toLowerCase())
158 // "slack_search_public" from Slack reads "Slack · search public".
159 if (words.length > 1 && words[0] === connector.split(' ')[0]!.toLowerCase()) words.shift()
160 return words.join(' ')
161}
162
163const base = (p: string) => p.split('/').filter(Boolean).pop() ?? p
164const cut = (t: string, n: number) => (n <= 0 ? '' : t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t)
165const oneLine = (t: string) => t.replace(/\s+/g, ' ').trim()
166
167function folder(p: string) {
168 const parts = p.split('/')
169 parts.pop()
170 const dir = parts.join('/')
171 return home && dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir
172}
173
174/** One tool call: its kind, a short label for the list, and plain words for the live line. */
175function describe(tool: string, args: Record<string, unknown>): Step {
176 const s = (k: string) => (typeof args[k] === 'string' ? oneLine(args[k] as string) : '')
177 const file = (verb: string, kind: StepKind, key = 'file_path'): Step => {
178 const p = typeof args[key] === 'string' ? (args[key] as string) : ''
179 const name = base(p) || 'a file'
180 return { kind, label: name, dim: p ? folder(p) : '', live: `${verb} ${name}` }
181 }
182 if (tool.startsWith('mcp__')) {
183 const { server, name: toolName } = splitTool(tool)
184 const name = connectorName(server, toolName)
185 const words = toolWords(toolName.replace(/__/g, '_'), name)
186 return { kind: 'Connector', label: `${name} · ${words}`, dim: '', live: `${name}: ${words}` }
187 }
188 switch (tool) {
189 case 'Read':
190 return file('Reading', 'File read')
191 case 'Write':
192 return file('Writing', 'File write')
193 case 'Edit':
194 case 'MultiEdit':
195 return file('Editing', 'File write')
196 case 'NotebookEdit':
197 return file('Editing', 'File write', 'notebook_path')
198 case 'Grep':
199 case 'Glob': {
200 const pat = s('pattern')
201 const p = s('path')
202 return {
203 kind: 'File read',
204 label: pat ? `Search "${cut(pat, 60)}"` : 'Search files',
205 dim: p ? (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p) : '',
206 live: pat ? `Searching files for "${cut(pat, 40)}"` : 'Searching files',
207 }
208 }
209 case 'Bash': {
210 const label = s('description') || cut(s('command'), 80) || 'a command'
211 return { kind: 'Command', label, dim: '', live: `Running: ${label}` }
212 }
213 case 'WebSearch':
214 return { kind: 'Web', label: s('query') || 'web search', dim: '', live: `Searching the web: ${s('query')}` }
215 case 'WebFetch': {
216 let host = 'a web page'
217 try {
218 host = new URL(s('url')).host
219 } catch {
220 // Not a URL: keep the plain words.
221 }
222 return { kind: 'Web', label: host, dim: '', live: `Opening ${host}` }
223 }
224 case 'Skill':
225 return { kind: 'Skill', label: s('skill') || 'a skill', dim: '', live: `Using the ${s('skill') || 'a'} skill` }
226 case 'Agent':
227 case 'Task':
228 return {
229 kind: 'Subagent',
230 label: s('description') || 'Start a subagent',
231 dim: '',
232 live: s('description') ? `Starting a subagent: ${s('description')}` : 'Starting a subagent',
233 }
234 case 'SendMessage':
235 return { kind: 'Subagent', label: 'Send a message', dim: '', live: 'Sending a message' }
236 case 'TodoWrite':
237 return { kind: 'Tool', label: 'Update its to-do list', dim: '', live: 'Updating its to-do list' }
238 case 'ToolSearch':
239 return { kind: 'Tool', label: 'Load tools', dim: '', live: 'Loading tools' }
240 default:
241 return { kind: 'Tool', label: tool, dim: '', live: `Using ${tool}` }
242 }
243}
244
245function clockTime(ms: number) {
246 const t = Math.max(0, Math.floor(ms / 1000))
247 const h = Math.floor(t / 3600)
248 const m = Math.floor((t % 3600) / 60)
249 const sec = String(t % 60).padStart(2, '0')
250 return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
251}
252
253function tokenText(n: number) {
254 if (n < 1000) return String(n)
255 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
256 return `${(n / 1_000_000).toFixed(1)}M`
257}
258
259/** "claude-opus-5-5" reads "Opus 5.5"; "haiku" reads "Haiku". */
260function shortModel(model: string) {
261 const hit = /(opus|sonnet|haiku|fable)(?:[-_ ](\d+))?(?:[-_.](\d+))?/i.exec(model)
262 if (!hit) return model
263 const fam = hit[1]![0]!.toUpperCase() + hit[1]!.slice(1).toLowerCase()
264 return hit[2] ? `${fam} ${hit[2]}${hit[3] ? `.${hit[3]}` : ''}` : fam
265}
266
267type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
268const usageTotal = (u: Usage) => u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
269
270const working = () => helpers.filter(hp => hp.phase === 'working').length
271
272/** The next colour in order that no running helper wears; with all 8 taken, the next in order. */
273function pickHue() {
274 const used = new Set(helpers.filter(hp => hp.phase === 'working').map(hp => hp.hue % HUES.length))
275 let hue = nextHue % HUES.length
276 for (let i = 0; i < HUES.length; i += 1) {
277 const h = (nextHue + i) % HUES.length
278 if (!used.has(h)) {
279 hue = h
280 break
281 }
282 }
283 nextHue = hue + 1
284 return hue
285}
286const copy = (hp: Helper): Helper => ({ ...hp, steps: hp.steps.map(st => ({ ...st })) })
287
288async function log($: EngineInterface, line: string) {
289 try {
290 const dir = `${home}/.claude/mods-logs`
291 const path = `${dir}/${NAME}.log`
292 let old = ''
293 if (await $.fs.exists(path).catch(() => false)) old = String(await $.fs.read(path))
294 else await $.process.run(['mkdir', '-p', dir])
295 await $.fs.write(path, `${old}${new Date().toISOString()} ${line}\n`)
296 } catch {
297 // Logging must never break the session.
298 }
299}
300
301async function tick($: EngineInterface) {
302 tickN += 1
303 await $.state.set(CLOCK, { tick: tickN, now: await $.clock.now() })
304}
305
306async function readCost($: EngineInterface) {
307 try {
308 const u = await $.session.usage()
309 await $.state.set(COST, { usd: typeof u.cost?.usd === 'number' ? u.cost.usd : null })
310 } catch {
311 // No ledger here: the tile stays hidden.
312 }
313}
314
315async function poll($: EngineInterface) {
316 if (!working()) return
317 const list = await $.agent.list()
318 const now = await $.clock.now()
319 let changed = false
320 for (const a of list) {
321 const hp = helpers.find(x => x.id === a.id)
322 if (!hp || hp.phase !== 'working') continue
323 // The list may lag a turn's end; it only ever moves a card out of working.
324 const next: HelperPhase | null =
325 a.status === 'completed' || a.status === 'idle' ? 'done' : a.status === 'failed' || a.status === 'killed' ? 'failed' : null
326 if (next) {
327 hp.phase = next
328 hp.endedAt = now
329 changed = true
330 }
331 if (!hp.description && a.description) {
332 hp.description = a.description
333 changed = true
334 }
335 }
336 if (changed) await publish($)
337 else await readCost($)
338}
339
340function syncTimers($: EngineInterface) {
341 if (working() && !off) {
342 ticker ??= $.clock.every(TICK_MS, () => {
343 void tick($).catch(() => undefined)
344 })
345 poller ??= $.clock.every(POLL_MS, () => {
346 void poll($).catch(err => log($, `poll: ${String(err)}`))
347 })
348 } else {
349 ticker?.cancel()
350 poller?.cancel()
351 ticker = null
352 poller = null
353 }
354}
355
356async function publish($: EngineInterface) {
357 await $.state.set(HELPERS, helpers.map(copy))
358 await $.state.set(CLOCK, { tick: tickN, now: await $.clock.now() })
359 await readCost($)
360 // No status line: on desktop it drew over Context Meter's footer bar. The pane shows the count.
361 $.ui.status(undefined)
362 syncTimers($)
363}
364
365async function addHelper($: EngineInterface, id: string, description: string, type: string, model: string) {
366 const known = helpers.find(hp => hp.id === id)
367 if (known) {
368 // Found first through its own tool calls: fill in what the spawn knows.
369 if (description) known.description = description
370 if (type) known.type = type
371 if (model && !known.model) known.model = model
372 await publish($)
373 return
374 }
375 const wasIdle = working() === 0
376 const now = await $.clock.now()
377 helpers.push({
378 id,
379 description: description || 'Subagent',
380 type: type || 'subagent',
381 model,
382 effort: '',
383 phase: 'working',
384 startedAt: now,
385 endedAt: 0,
386 count: 0,
387 steps: [],
388 tokens: 0,
389 turnTokens: 0,
390 hasUsage: false,
391 seat: seats,
392 hue: pickHue(),
393 })
394 seats += 1
395 await publish($)
396 // The pane opens by itself when a new round of helpers starts.
397 if (wasIdle && !hidden) void $.ui.open({ id: PANE, title: TITLE }).catch(err => log($, `open: ${String(err)}`))
398}
399
400/** A loop whose spawn this mod missed (a reload, a race): take it from $.agent.list(). */
401async function adopt($: EngineInterface, id: string) {
402 if (helpers.some(hp => hp.id === id) || strangers.has(id)) return
403 const a = (await $.agent.list()).find(x => x.id === id)
404 if (!a) {
405 // The engine's own forks carry ids no list names: leave them out.
406 strangers.add(id)
407 return
408 }
409 await addHelper($, id, a.description, a.type, '')
410}
411
412async function noteTool($: EngineInterface, id: string, step: Step) {
413 if (!helpers.some(x => x.id === id)) await adopt($, id)
414 const hp = helpers.find(x => x.id === id)
415 if (!hp) return
416 hp.count += 1
417 hp.steps = [...hp.steps, step].slice(-MAX_STEPS)
418 // A teammate that finished a turn and picks up work again is working again.
419 if (hp.phase !== 'working') {
420 hp.phase = 'working'
421 hp.endedAt = 0
422 }
423 await publish($)
424}
425
426async function noteStep($: EngineInterface, id: string, model: string, effort: string, usage: Usage | null) {
427 if (!helpers.some(x => x.id === id)) await adopt($, id)
428 const hp = helpers.find(x => x.id === id)
429 if (!hp) return
430 if (model) hp.model = shortModel(model)
431 if (effort) hp.effort = effort
432 if (usage) {
433 hp.turnTokens += usageTotal(usage)
434 hp.hasUsage = true
435 }
436 await publish($)
437}
438
439async function finish($: EngineInterface, id: string, phase: HelperPhase, usage: Usage | null) {
440 const hp = helpers.find(x => x.id === id)
441 if (!hp) return
442 // The turn's own sum wins over the steps counted so far.
443 hp.tokens += usage ? usageTotal(usage) : hp.turnTokens
444 hp.turnTokens = 0
445 if (usage) hp.hasUsage = true
446 hp.phase = phase
447 hp.endedAt = await $.clock.now()
448 await publish($)
449}
450
451async function setOff($: EngineInterface, value: boolean) {
452 if (value === off) return
453 off = value
454 await $.state.set(OFF, off)
455 if (off) {
456 $.ui.status(undefined)
457 await $.ui.close({ id: PANE }).catch(() => undefined)
458 }
459 syncTimers($)
460 syncShimmer($)
461}
462
463const HIDE_KEY = `${NAME}:hidden`
464const switchesPath = () => `${home}/.claude/mods/switches.json`
465
466async function readSwitches($: EngineInterface): Promise<Record<string, unknown>> {
467 if (!(await $.fs.exists(switchesPath()))) return {}
468 const json = JSON.parse(String(await $.fs.read(switchesPath()))) as unknown
469 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
470}
471
472async function readSwitch($: EngineInterface) {
473 try {
474 const json = await readSwitches($)
475 const nowHidden = json[`${NAME}:hidden`] === true
476 if (nowHidden && !hidden) await $.ui.close({ id: PANE }).catch(() => undefined)
477 if (nowHidden !== hidden) {
478 hidden = nowHidden
479 // The band reads this key, so a change redraws it.
480 await $.state.set(HIDDEN, hidden)
481 syncShimmer($)
482 }
483 await setOff($, json[NAME] === false)
484 } catch {
485 // A broken file never turns the mod off.
486 }
487}
488
489// Read, change, write the whole file, so every chat sees the choice within 3 s.
490async function setHidden($: EngineInterface, value: boolean) {
491 try {
492 const json = await readSwitches($)
493 if (value) json[HIDE_KEY] = true
494 else delete json[HIDE_KEY]
495 await $.process.run(['mkdir', '-p', `${home}/.claude/mods`])
496 await $.fs.write(switchesPath(), `${JSON.stringify(json, null, 2)}\n`)
497 } catch (err) {
498 await log($, `save hidden: ${String(err)}`)
499 }
500 hidden = value
501 await $.state.set(HIDDEN, hidden)
502 syncShimmer($)
503 if (value) await $.ui.close({ id: PANE }).catch(() => undefined)
504}
505
506async function openPane($: EngineInterface) {
507 await $.ui.open({ id: PANE, title: TITLE })
508}
509
510async function checkOpen($: EngineInterface) {
511 if (!sessionId) return
512 const path = `${home}/.claude/mods/.open-${NAME}-${sessionId}`
513 if (!(await $.fs.exists(path).catch(() => false))) return
514 await $.process.run(['rm', '-f', path])
515 if (!off) await openPane($)
516}
517
518async function switchPoll($: EngineInterface) {
519 await readSwitch($)
520 await checkOpen($).catch(err => log($, `open request: ${String(err)}`))
521}
522
523// ---------- The plan: one list per chat, shared with chat-inventory through a file ----------
524
525const PLAN_TOOL = 'mcp__helper-desk__plan'
526const MIRRORED = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
527const PLAN_PROMPT =
528 'Your to-do list is the mcp__helper-desk__plan tool (this app has no TodoWrite). The user watches it as a progress bar. ' +
529 'For any task that needs 2 or more steps or tool calls, call it first, before other tools, with every step in order and the first one in_progress. ' +
530 'Call it again with the full list each time a step finishes or a new one starts. Mark the last step completed before your final answer. ' +
531 'When you write a plan for the user to approve, save its steps with the tool too, all pending, so the plan shows in the bar and the Chat Inventory to-do list. ' +
532 'A step that launches subagents is one step. Skip it only for a one-line answer with no tool calls.'
533// Main-chat tool calls in a turn before the nudge to make a plan.
534const NUDGE_AFTER = 1
535// Tool calls since the last plan update before the nudge to tick it.
536const STALE_AFTER = 8
537const NUDGE_NEW =
538 'Helper Desk: you have no plan for this task yet. Call mcp__helper-desk__plan now with every step in order, so the user sees your progress.'
539const NUDGE_CONT =
540 'Helper Desk: the plan has not changed this turn. If this is a new task, call mcp__helper-desk__plan with its steps. If it carries on the last one, send the updated list.'
541const NUDGE_STALE =
542 'Helper Desk: the plan has not changed in a while. If a step finished, call mcp__helper-desk__plan with the full list now.'
543const STATUSES: readonly PlanStatus[] = ['pending', 'in_progress', 'completed']
544const SHIMMER_MS = 350
545// How long a finished plan stays at 100% before the band goes away.
546const DONE_HOLD_MS = 20_000
547const BAR_FALLBACK = 30
548
549type PlanStep = { text: string; status: PlanStatus; doneAt?: string; id?: string }
550type Plan = { title: string; steps: PlanStep[]; updatedAt: string }
551type StepIn = { text: string; status: PlanStatus; id?: string }
552
553let plan: Plan | null = null
554// True once every step is done; a list with work left in it starts the next plan.
555let planDone = false
556let planShown = false
557let shimmer: { cancel: () => void } | null = null
558let holdTimer: { cancel: () => void } | null = null
559let shimN = 0
560// The plan nudge: main-chat tool calls this turn, and whether a plan came in.
561let turnCalls = 0
562let turnPlanned = false
563let nudged = false
564let sincePlan = 0
565let staleNudged = false
566
567// A short reminder after a tool's result, read by the model only.
568function withNudge<R>(r: R): R {
569 turnCalls++
570 sincePlan++
571 const live = !!plan && !planDone && plan.steps.length > 0
572 let note: string | null = null
573 if (!turnPlanned && !nudged && turnCalls >= NUDGE_AFTER) {
574 nudged = true
575 note = live ? NUDGE_CONT : NUDGE_NEW
576 } else if (live && !staleNudged && sincePlan >= STALE_AFTER) {
577 staleNudged = true
578 note = NUDGE_STALE
579 }
580 if (!note || !r || typeof r !== 'object' || 'deny' in (r as object)) return r
581 const had = (r as { context?: readonly string[] }).context ?? []
582 return { ...r, context: [...had, note] }
583}
584let stateDirMade = false
585
586const planPath = () => `${home}/.claude/mods-state/plan-${sessionId}.json`
587const doneCount = (steps: { status: PlanStatus }[]) => steps.filter(st => st.status === 'completed').length
588const isStatus = (v: unknown): v is PlanStatus => typeof v === 'string' && (STATUSES as readonly string[]).includes(v)
589
590async function writePlan($: EngineInterface) {
591 if (!plan || !sessionId || !home) return
592 try {
593 if (!stateDirMade) {
594 await $.process.run(['mkdir', '-p', `${home}/.claude/mods-state`])
595 stateDirMade = true
596 }
597 const body = {
598 title: plan.title,
599 steps: plan.steps.map(st => ({ text: st.text, status: st.status, ...(st.doneAt ? { doneAt: st.doneAt } : {}) })),
600 updatedAt: plan.updatedAt,
601 }
602 await $.fs.write(planPath(), `${JSON.stringify(body, null, 2)}\n`)
603 } catch (err) {
604 await log($, `write plan: ${String(err)}`)
605 }
606}
607
608async function drawPlan($: EngineInterface) {
609 const value: BandPlan | null = plan
610 ? { title: plan.title, steps: plan.steps.map(st => ({ text: st.text, status: st.status })), shown: planShown }
611 : null
612 await $.state.set(PLAN, value)
613 syncShimmer($)
614}
615
616/** The shimmer clock runs only while a shown plan has a step in progress. */
617function syncShimmer($: EngineInterface) {
618 const need = !off && !hidden && planShown && !!plan?.steps.some(st => st.status === 'in_progress')
619 if (need) {
620 shimmer ??= $.clock.every(SHIMMER_MS, () => {
621 shimN += 1
622 void $.state.set(SHIM, shimN).catch(() => undefined)
623 })
624 } else {
625 shimmer?.cancel()
626 shimmer = null
627 }
628}
629
630function say($: EngineInterface, text: string) {
631 try {
632 $.ui.toast(cut(text, 140))
633 } catch {
634 // A toast is only a hint.
635 }
636}
637
638/** Takes a whole new list, keeps done times, toasts what just finished, and saves. */
639async function applyPlan($: EngineInterface, title: string | undefined, incoming: StepIn[]) {
640 const now = new Date(await $.clock.now()).toISOString()
641 const allDone = incoming.length > 0 && incoming.every(st => st.status === 'completed')
642 // A list with work left in it after a finished plan is a new plan.
643 const fresh = !plan || (planDone && !allDone)
644 const old = fresh ? [] : plan!.steps
645 const used = new Set<number>()
646 const match = (st: StepIn) => {
647 const i = old.findIndex((o, k) => !used.has(k) && (st.id ? o.id === st.id : o.text === st.text))
648 if (i >= 0) used.add(i)
649 return i >= 0 ? old[i] : undefined
650 }
651 const justDone: string[] = []
652 const steps: PlanStep[] = incoming.map(st => {
653 const was = match(st)
654 const step: PlanStep = { text: st.text, status: st.status, ...(st.id ? { id: st.id } : {}) }
655 if (st.status === 'completed') {
656 step.doneAt = was?.status === 'completed' && was.doneAt ? was.doneAt : now
657 if (was?.status !== 'completed' && !fresh) justDone.push(st.text)
658 }
659 return step
660 })
661 const wasDone = planDone && !fresh
662 plan = { title: title?.trim() || (fresh ? 'Plan' : plan!.title), steps, updatedAt: now }
663 planDone = allDone
664 if (!steps.length) planShown = false
665 else if (allDone && !wasDone) {
666 planShown = true
667 say($, steps.length === 1 ? 'All steps done.' : `All ${steps.length} steps done.`)
668 holdTimer?.cancel()
669 holdTimer = $.clock.after(DONE_HOLD_MS, () => {
670 holdTimer = null
671 planShown = false
672 void drawPlan($).catch(err => log($, `hide plan: ${String(err)}`))
673 })
674 } else if (!allDone) {
675 planShown = true
676 holdTimer?.cancel()
677 holdTimer = null
678 if (justDone.length) say($, `Done: ${justDone.join(', ')}`)
679 }
680 await writePlan($)
681 await drawPlan($)
682}
683
684/** The plan tool's own call: check the input, save it, answer in one line. */
685async function planFromTool($: EngineInterface, args: Record<string, unknown>) {
686 const raw = args.steps
687 if (!Array.isArray(raw)) return 'Plan not saved: send steps as a list of { text, status }.'
688 const steps: StepIn[] = []
689 for (const st of raw as unknown[]) {
690 const o = (st && typeof st === 'object' ? st : {}) as Record<string, unknown>
691 const text = typeof o.text === 'string' ? oneLine(o.text) : ''
692 if (!text) return 'Plan not saved: every step needs its text.'
693 if (!isStatus(o.status)) return `Plan not saved: "${cut(text, 40)}" needs a status of pending, in_progress or completed.`
694 steps.push({ text, status: o.status })
695 }
696 await applyPlan($, typeof args.title === 'string' ? oneLine(args.title) : undefined, steps)
697 return `Plan saved: ${doneCount(steps)} of ${steps.length} steps done.`
698}
699
700/** TodoWrite, TaskCreate and TaskUpdate on the main chat feed the same plan. */
701async function mirror($: EngineInterface, tool: string, args: Record<string, unknown>, r: unknown) {
702 const res = (r as { result?: unknown; isError?: boolean; deny?: string } | undefined) ?? {}
703 if (res.deny || res.isError) return
704 if (tool === 'TodoWrite') {
705 const todos = Array.isArray(args.todos) ? (args.todos as unknown[]) : []
706 const steps: StepIn[] = []
707 for (const t of todos) {
708 const o = (t && typeof t === 'object' ? t : {}) as Record<string, unknown>
709 const text = typeof o.content === 'string' ? oneLine(o.content) : ''
710 if (text) steps.push({ text, status: isStatus(o.status) ? o.status : 'pending' })
711 }
712 await applyPlan($, undefined, steps)
713 return
714 }
715 const current: StepIn[] = plan && !planDone ? plan.steps.map(st => ({ text: st.text, status: st.status, ...(st.id ? { id: st.id } : {}) })) : []
716 if (tool === 'TaskCreate') {
717 const text = typeof args.subject === 'string' ? oneLine(args.subject) : ''
718 if (!text) return
719 const out = (res.result ?? {}) as { task?: { id?: unknown } }
720 const id = typeof out.task?.id === 'string' ? out.task.id : undefined
721 await applyPlan($, undefined, [...current, { text, status: 'pending', ...(id ? { id } : {}) }])
722 return
723 }
724 // TaskUpdate: find the task by its id; an unknown one with a subject joins the list.
725 const id = typeof args.taskId === 'string' ? args.taskId : ''
726 const all: StepIn[] = plan ? plan.steps.map(st => ({ text: st.text, status: st.status, ...(st.id ? { id: st.id } : {}) })) : []
727 const i = all.findIndex(st => st.id === id)
728 const subject = typeof args.subject === 'string' ? oneLine(args.subject) : ''
729 if (args.status === 'deleted') {
730 if (i >= 0) await applyPlan($, undefined, all.filter((_, k) => k !== i))
731 return
732 }
733 const status = isStatus(args.status) ? args.status : undefined
734 if (i < 0) {
735 if (subject) await applyPlan($, undefined, [...current, { text: subject, status: status ?? 'pending', id }])
736 return
737 }
738 all[i] = { ...all[i]!, ...(subject ? { text: subject } : {}), ...(status ? { status } : {}) }
739 await applyPlan($, undefined, all)
740}
741
742/** A reload or resume carries on with the plan this chat already saved. */
743async function loadPlan($: EngineInterface) {
744 if (plan || !sessionId || !home) return
745 try {
746 if (!(await $.fs.exists(planPath()))) return
747 const json = JSON.parse(String(await $.fs.read(planPath()))) as { title?: unknown; steps?: unknown; updatedAt?: unknown }
748 const steps: PlanStep[] = (Array.isArray(json.steps) ? (json.steps as Record<string, unknown>[]) : [])
749 .filter(st => st && typeof st.text === 'string' && isStatus(st.status))
750 .map(st => ({ text: String(st.text), status: st.status as PlanStatus, ...(typeof st.doneAt === 'string' ? { doneAt: st.doneAt } : {}) }))
751 plan = {
752 title: typeof json.title === 'string' && json.title ? json.title : 'Plan',
753 steps,
754 updatedAt: typeof json.updatedAt === 'string' ? json.updatedAt : '',
755 }
756 planDone = steps.length > 0 && steps.every(st => st.status === 'completed')
757 planShown = steps.length > 0 && !planDone
758 await drawPlan($)
759 } catch (err) {
760 await log($, `load plan: ${String(err)}`)
761 }
762}
763
764async function toggleIn($: EngineInterface, field: 'open' | 'folded', id: string) {
765 const { value = { open: [], folded: [] } } = await $.state.get(VIEW)
766 const list = value[field]
767 const nextList = list.includes(id) ? list.filter(x => x !== id) : [...list, id]
768 await $.state.set(VIEW, { ...value, [field]: nextList })
769}
770
771// ---------- Drawing ----------
772
773/** A label then dim text, both cut to fit `w` cells; the folder gives way first. */
774function fitRow(label: string, dim: string, w: number): [string, string] {
775 if (label.length >= w || !dim) return [cut(label, w), '']
776 const room = w - label.length - 2
777 if (room < 4) return [label, '']
778 return [label, dim.length > room ? `…${dim.slice(-(room - 1))}` : dim]
779}
780
781/** The terminal bar: one thick row of blocks, full when over, a sliding piece while working. */
782function barParts(phase: HelperPhase, w: number, tick: number, seat: number): [string, string, string] {
783 if (phase !== 'working') return ['', '█'.repeat(w), '']
784 const piece = Math.max(3, Math.floor(w / 4))
785 const span = w + piece
786 const head = ((tick + seat * 5 + piece) % span) - piece
787 const from = Math.max(0, head)
788 const to = Math.min(w, head + piece)
789 return ['░'.repeat(from), '█'.repeat(Math.max(0, to - from)), '░'.repeat(w - Math.max(from, to))]
790}
791
792// ---------- Sparkle bars: SVG for the desktop ----------
793
794const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
795const CELL = 3
796
797// Fixed noise, so the sparkles sit still between redraws and only their CSS twinkle moves.
798const noise = (x: number, y: number) => {
799 const v = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
800 return v - Math.floor(v)
801}
802const xml = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
803// A rough width of system UI text, enough to place the bar after the title.
804const textPx = (s: string, size: number) => [...s].reduce((n, ch) => n + (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56) * size, 0)
805const fitPx = (s: string, size: number, max: number) => {
806 if (textPx(s, size) <= max) return s
807 let out = ''
808 for (const ch of s) {
809 if (textPx(`${out}${ch}…`, size) > max) break
810 out += ch
811 }
812 return `${out}…`
813}
814
815/** Twinkle classes s0 to s3, out of phase; `slow` for a finished bar. */
816const twinkleCss = (slow: boolean) =>
817 `.s0,.s1,.s2,.s3{animation:tw ${slow ? 3.2 : 2.1}s ease-in-out infinite}` +
818 `.s1{animation-duration:${slow ? 3.8 : 2.7}s;animation-delay:-.7s}.s2{animation-duration:${slow ? 4.4 : 1.8}s;animation-delay:-1.3s}` +
819 `.s3{animation-duration:${slow ? 3.5 : 3.1}s;animation-delay:-.4s}` +
820 `@keyframes tw{0%,100%{opacity:.95}50%{opacity:${slow ? 0.55 : 0.15}}}`
821
822/** Dither squares over `w` by `h` pixels: sparse at the left, dense at the right. */
823function sparkles(w: number, h: number, size: number, base: number, rise: number, salt = 0): string {
824 const cols = Math.floor(w / CELL)
825 const rows = Math.max(1, Math.floor(h / CELL))
826 const out: string[] = []
827 for (let c = 0; c < cols; c++) {
828 const density = base + rise * Math.pow(c / Math.max(1, cols), 1.3)
829 for (let r = 0; r < rows; r++) {
830 if (noise(c + salt, r) < density) {
831 out.push(`<rect class="s${Math.floor(noise(r, c + salt) * 4)}" x="${c * CELL + 0.5}" y="${r * CELL + (h - rows * CELL) / 2 + 0.5}" width="${size}" height="${size}"/>`)
832 }
833 }
834 }
835 return out.join('')
836}
837
838/** A colour between `a` and `b`, both #rrggbb; `t` from 0 to 1. */
839function mix(a: string, b: string, t: number): string {
840 const at = (h: string, i: number) => parseInt(h.slice(1 + i * 2, 3 + i * 2), 16)
841 return `#${[0, 1, 2].map(i => Math.round(at(a, i) + (at(b, i) - at(a, i)) * t).toString(16).padStart(2, '0')).join('')}`
842}
843
844const BAND_H = 24
845const TRACK_H = 14
846
847/** The task bar above the prompt, one drawing: dot, title, purple gradient track, ticks, Tasks pill, percent. */
848function bandSvg(p: BandPlan, W: number): string {
849 const n = p.steps.length
850 const done = doneCount(p.steps)
851 const ratio = n ? done / n : 0
852 const title = fitPx(p.title, 13, Math.max(60, W * 0.35))
853 const percent = `${Math.round(ratio * 100)}%`
854 const barX = Math.round(14 + textPx(title, 13) + 12)
855 const barW = Math.max(60, W - barX - 44)
856 const y0 = (BAND_H - TRACK_H) / 2
857 const fillW = Math.round(barW * ratio)
858 // The step in progress glows softly from the fill to that step's end.
859 const live = p.steps.some(st => st.status === 'in_progress')
860 const liveW = live ? Math.round((barW * Math.min(n, done + 1)) / n) - fillW : 0
861 const ticks: string[] = []
862 for (let i = 1; i < n; i++) {
863 const x = Math.round((barW * i) / n)
864 if (x > fillW + 3) ticks.push(`<rect x="${x}" y="${TRACK_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
865 }
866 const label = `Tasks ${done}/${n}`
867 const pillW = Math.round(18 + label.length * 6.4)
868 const pillX = Math.max(0, Math.min(barW - pillW, fillW - pillW))
869 const finished = n > 0 && done === n
870 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${BAND_H}" viewBox="0 0 ${W} ${BAND_H}">
871<style>
872.tt{fill:#1f1f1f}.pc{fill:#6b6b6b}.k{fill:#ece9fb}.tk{fill:#c9c2f0}
873@media (prefers-color-scheme: dark){.tt{fill:#ececec}.pc{fill:#a3a3a3}.k{fill:#2a2640}.tk{fill:#4c4470}}
874${twinkleCss(finished)}
875.lv{animation:gl 1.6s ease-in-out infinite}@keyframes gl{0%,100%{opacity:.18}50%{opacity:.5}}
876.sh{animation:sw 2.6s linear infinite}@keyframes sw{from{transform:translateX(-70px)}to{transform:translateX(${fillW + 70}px)}}
877@media (prefers-reduced-motion: reduce){.s0,.s1,.s2,.s3,.lv,.sh{animation:none}}
878</style>
879<defs>
880<linearGradient id="hdb-g" x1="0" x2="1"><stop offset="0" stop-color="${P_LIGHT}"/><stop offset=".55" stop-color="${P_MID}"/><stop offset="1" stop-color="${P_DEEP}"/></linearGradient>
881<linearGradient id="hdb-s" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".5"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
882<clipPath id="hdb-c"><rect width="${barW}" height="${TRACK_H}" rx="${TRACK_H / 2}"/></clipPath>
883<clipPath id="hdb-f"><rect width="${fillW}" height="${TRACK_H}"/></clipPath>
884</defs>
885<circle cx="5" cy="${BAND_H / 2}" r="4" fill="${P_DEEP}"/>
886<text class="tt" x="14" y="${BAND_H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="600">${xml(title)}</text>
887<g transform="translate(${barX},${y0})">
888<rect class="k" width="${barW}" height="${TRACK_H}" rx="${TRACK_H / 2}"/>
889<g clip-path="url(#hdb-c)">
890${liveW > 0 ? `<rect class="lv" x="${fillW}" width="${liveW}" height="${TRACK_H}" fill="${P_MID}"/>` : ''}
891${fillW > 0 ? `<rect width="${fillW}" height="${TRACK_H}" fill="url(#hdb-g)"/>` : ''}
892<g fill="#ffffff">${sparkles(fillW, TRACK_H, 1.6, 0.12, 0.4)}</g>
893${fillW > 0 && !finished ? `<g clip-path="url(#hdb-f)"><rect class="sh" width="70" height="${TRACK_H}" fill="url(#hdb-s)"/></g>` : ''}
894<g class="tk">${ticks.join('')}</g>
895</g>
896<rect x="${pillX}" width="${pillW}" height="${TRACK_H}" rx="${TRACK_H / 2}" fill="${P_DEEP}"/>
897<text x="${pillX + pillW / 2}" y="${TRACK_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(label)}</text>
898</g>
899<text class="pc" x="${W - 4}" y="${BAND_H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
900</svg>`
901}
902
903const CARD_BAR_H = 10
904
905/**
906 * One thick sparkle bar per subagent card. While it works a bright piece sweeps the track
907 * (its length is not known); once over the whole bar fills and glows slowly. The drawing
908 * depends only on the phase, so CSS moves it and redraws do not restart it.
909 */
910function cardBarSvg(phase: HelperPhase, color: string, W: number, seat: number): string {
911 const id = `hdc${seat}`
912 const H = CARD_BAR_H
913 const fill = phase === 'failed' ? BAD : color
914 const piece = Math.max(30, Math.round(W * 0.35))
915 const working = phase === 'working'
916 const body = working
917 ? `<g class="mv"><rect width="${piece}" height="${H}" fill="url(#${id}-p)"/><g fill="#ffffff">${sparkles(piece, H, 1.6, 0.1, 0.35, seat * 11)}</g></g>`
918 : `<rect width="${W}" height="${H}" fill="url(#${id}-g)"/><g fill="#ffffff">${sparkles(W, H, 1.6, 0.18, 0.3, seat * 11)}</g>`
919 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
920<style>
921${twinkleCss(!working)}
922.mv{animation:mv${seat} 1.8s ease-in-out infinite;animation-delay:-${(seat * 0.37) % 1.8}s}
923@keyframes mv${seat}{from{transform:translateX(-${piece}px)}to{transform:translateX(${W}px)}}
924@media (prefers-reduced-motion: reduce){.s0,.s1,.s2,.s3,.mv{animation:none}}
925</style>
926<defs>
927<linearGradient id="${id}-g" x1="0" x2="1"><stop offset="0" stop-color="${fill}" stop-opacity=".55"/><stop offset="1" stop-color="${fill}"/></linearGradient>
928<linearGradient id="${id}-p" x1="0" x2="1"><stop offset="0" stop-color="${fill}" stop-opacity="0"/><stop offset=".7" stop-color="${fill}"/><stop offset="1" stop-color="${fill}" stop-opacity=".9"/></linearGradient>
929<clipPath id="${id}-c"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath>
930</defs>
931<g clip-path="url(#${id}-c)">
932<rect width="${W}" height="${H}" fill="${fill}" opacity=".16"/>
933${body}
934</g>
935</svg>`
936}
937
938export const register: Register = on => {
939 on('session.start', async ($, e, next) => {
940 home = $.plugin.root.split('/.claude/')[0] ?? ''
941 sessionId = await $.session.id().catch(() => '')
942 await $.command.register({ name: 'helpers', description: 'Show the Helper Desk: the task progress bar and one live card per subagent', immediate: true })
943 await $.tool.register({
944 name: 'plan',
945 description:
946 'Save the plan for the job in front of you: the steps in order, each pending, in_progress or completed. ' +
947 'It draws a progress bar above the prompt for the user. Send the whole list every time, like TodoWrite.',
948 inputSchema: {
949 type: 'object',
950 properties: {
951 title: { type: 'string', description: 'A short name for the job, a few words.' },
952 steps: {
953 type: 'array',
954 description: 'Every step, in order.',
955 items: {
956 type: 'object',
957 properties: {
958 text: { type: 'string', description: 'The step in a few plain words.' },
959 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
960 },
961 required: ['text', 'status'],
962 },
963 },
964 },
965 required: ['steps'],
966 },
967 // In front, not behind ToolSearch: a hidden tool never gets called.
968 isDeferred: false,
969 }).catch(err => log($, `register plan tool: ${String(err)}`))
970 await readSwitch($)
971 await loadPlan($)
972 switcher ??= $.clock.every(SWITCH_MS, () => {
973 void switchPoll($).catch(err => log($, `switch: ${String(err)}`))
974 })
975 // A reload keeps the drawn copy: carry on from it.
976 if (!helpers.length) {
977 const { value } = await $.state.get(HELPERS)
978 if (value?.length) {
979 // A copy from before body colours has no hue: its seat picks one.
980 helpers = value.map(hp => ({ ...copy(hp), hue: typeof hp.hue === 'number' ? hp.hue : hp.seat % HUES.length }))
981 seats = Math.max(...helpers.map(hp => hp.seat)) + 1
982 nextHue = (Math.max(...helpers.map(hp => hp.hue)) + 1) % HUES.length
983 syncTimers($)
984 }
985 }
986 return next(e)
987 })
988
989 on('command.run', { command: 'helpers' }, async $ => {
990 if (off) return { text: `${TITLE} is off. Turn it on with /mods.` }
991 if (hidden) await setHidden($, false)
992 await openPane($)
993 return { text: helpers.length ? `${TITLE} opened.` : `${TITLE} opened. Cards show up when Claude starts a subagent.` }
994 })
995
996 on('ui.press', { plugin: NAME, element: 'hide' }, async ($, e) => {
997 await setHidden($, true)
998 try {
999 $.ui.toast(`${TITLE} hidden. Type /helpers or open /mods to show it.`)
1000 } catch {
1001 // The toast is only a hint.
1002 }
1003 return { element: e.element }
1004 })
1005
1006 // ✕ on the task bar hides it until the plan changes again.
1007 on('ui.press', { plugin: NAME, element: 'plan-dismiss' }, async ($, e) => {
1008 planShown = false
1009 holdTimer?.cancel()
1010 holdTimer = null
1011 await drawPlan($).catch(err => log($, `dismiss plan: ${String(err)}`))
1012 return { element: e.element }
1013 })
1014
1015 on('ui.press', { plugin: NAME }, async ($, e, next) => {
1016 if (off) return next(e)
1017 if (e.element.startsWith('steps-')) {
1018 await toggleIn($, 'open', e.element.slice('steps-'.length))
1019 return { element: e.element }
1020 }
1021 if (e.element.startsWith('group-')) {
1022 await toggleIn($, 'folded', e.element.slice('group-'.length))
1023 return { element: e.element }
1024 }
1025 return next(e)
1026 })
1027
1028 on('agent.spawn', async ($, e, next) => {
1029 const r = await next(e)
1030 if (off) return r
1031 const id = (r as { agentId?: string }).agentId
1032 if (id) {
1033 void addHelper($, id, e.description || 'Subagent', e.subagentType || 'subagent', shortModel(e.model ?? r.model ?? '')).catch(err =>
1034 log($, `spawn: ${String(err)}`),
1035 )
1036 }
1037 return r
1038 })
1039
1040 // One unfiltered hook: a typed tool matcher makes tsc recurse through the connector types.
1041 on('tool.call', async ($, e, next) => {
1042 const tool = e.tool as string
1043 const args = e as unknown as Record<string, unknown>
1044 const id = e.agentId
1045 if (tool === PLAN_TOOL) {
1046 if (off) return { result: `${TITLE} is off, so no plan was saved. Carry on without it.` } as never
1047 if (id) return { result: 'Only the main chat keeps a plan. Carry on with your task.' } as never
1048 let said = 'Plan not saved: something went wrong. Carry on.'
1049 try {
1050 said = await planFromTool($, args)
1051 } catch (err) {
1052 await log($, `plan: ${String(err)}`)
1053 }
1054 turnPlanned = true
1055 sincePlan = 0
1056 staleNudged = false
1057 return { result: said } as never
1058 }
1059 if (off) return next(e)
1060 if (id) {
1061 void noteTool($, id, describe(tool, args)).catch(err => log($, `tool: ${String(err)}`))
1062 return next(e)
1063 }
1064 if (MIRRORED.has(tool)) {
1065 const r = await next(e)
1066 turnPlanned = true
1067 sincePlan = 0
1068 void mirror($, tool, args, r).catch(err => log($, `mirror ${tool}: ${String(err)}`))
1069 return r
1070 }
1071 const r = await next(e)
1072 return withNudge(r)
1073 })
1074
1075 // A new prompt is a new task: the plan nudge counts from zero.
1076 on('prompt.submit', async ($, e, next) => {
1077 turnCalls = 0
1078 turnPlanned = false
1079 nudged = false
1080 sincePlan = 0
1081 staleNudged = false
1082 return next(e)
1083 })
1084
1085 // A short system-prompt section, only while the mod is on.
1086 on('prompt.compose', async ($, e, next) => {
1087 const r = await next(e)
1088 if (off || e.traits.includes('teammate') || e.traits.includes('bare')) return r
1089 return { sections: [...r.sections, { id: 'helper-desk:plan', text: PLAN_PROMPT, scope: 'session' as const }] }
1090 })
1091
1092 // Each model request of a helper: its model, effort and token counts.
1093 on('turn.step', async function* ($, e, next) {
1094 const r = yield* next(e)
1095 const id = e.agentId
1096 if (id && !off) {
1097 const effort = e.effort === undefined ? '' : String(e.effort)
1098 void noteStep($, id, e.model, effort, r.usage ?? null).catch(err => log($, `step: ${String(err)}`))
1099 }
1100 return r
1101 })
1102
1103 on('turn.complete', async ($, e, next) => {
1104 const r = await next(e)
1105 const id = e.agentId
1106 if (id && !off && helpers.some(hp => hp.id === id)) {
1107 const failed = e.reason === 'error' || e.reason === 'refusal' || e.isAborted
1108 void finish($, id, failed ? 'failed' : 'done', e.usage ?? null).catch(err => log($, `turn: ${String(err)}`))
1109 }
1110 return r
1111 })
1112
1113 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1114 const { value: isOff = false } = await $.state.get(OFF)
1115 if (isOff || off) return next(e)
1116 const els = $.ui.resolve(e)
1117 const { Box, Text, Button } = els
1118 // Svg draws on desktop, the editor and mobile; the terminal gets a glyph.
1119 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
1120 const { value: list = [] } = await $.state.get(HELPERS)
1121 const { value: clk = { tick: 0, now: 0 } } = await $.state.get(CLOCK)
1122 const { value: view = { open: [], folded: [] } } = await $.state.get(VIEW)
1123 const { value: cost = { usd: null } } = await $.state.get(COST)
1124 const width = Math.max(32, e.props.bodyColumns)
1125 // Inside a card: its border and padding take 4 columns, the pet PET_W more.
1126 const cw = width - 4 - PET_W
1127
1128 const running = list.filter(hp => hp.phase === 'working')
1129 const finished = list.filter(hp => hp.phase === 'done')
1130 const stopped = list.filter(hp => hp.phase === 'failed')
1131
1132 // Header tiles: only numbers the engine gave.
1133 const tiles: { label: string; value: string }[] = []
1134 if (cost.usd !== null) tiles.push({ label: 'Total chat cost', value: `$${cost.usd.toFixed(2)}` })
1135 if (list.some(hp => hp.hasUsage)) tiles.push({ label: 'Subagent tokens', value: tokenText(list.reduce((n, hp) => n + hp.tokens + hp.turnTokens, 0)) })
1136 if (list.length) {
1137 const start = Math.min(...list.map(hp => hp.startedAt))
1138 const end = running.length ? Math.max(clk.now, ...running.map(hp => hp.startedAt)) : Math.max(...list.map(hp => hp.endedAt))
1139 tiles.push({ label: 'Time', value: clockTime(end - start) })
1140 }
1141 const tileW = tiles.length ? Math.floor((width - (tiles.length - 1)) / tiles.length) : width
1142
1143 // A running pet steps through its frames on the clock tick; a finished one stands still.
1144 const frameOf = (hp: Helper) => (hp.phase === 'working' ? Math.floor(clk.tick / FRAME_TICKS) % FRAMES : 0)
1145
1146 const pet = (hp: Helper) => {
1147 const hue = hueOf(hp.hue)
1148 const frame = frameOf(hp)
1149 if (Svg) {
1150 return (
1151 <Box key={`pet-${hp.id}`} width={PET_W} flexShrink={0}>
1152 <Svg source={petSvg(hp.hue, frame)} alt={`Claude crab, ${hue.name}`} width={PET_PX_W} height={PET_PX_H} />
1153 </Box>
1154 )
1155 }
1156 return (
1157 <Box key={`pet-${hp.id}`} width={PET_W} flexShrink={0} flexDirection="column">
1158 <Text color={hue.color}>{frame % 2 ? ' ▜█▛' : ' ▟█▙'}</Text>
1159 <Text color={hue.color}>{frame % 2 ? ' ▘ ▝' : ' ▘▝'}</Text>
1160 </Box>
1161 )
1162 }
1163
1164 const card = (hp: Helper) => {
1165 const hue = hueOf(hp.hue)
1166 const live = hp.phase === 'working'
1167 const isOpen = view.open.includes(hp.id)
1168 const mark = live ? '●' : hp.phase === 'done' ? '✓' : '✗'
1169 const markColor = hp.phase === 'failed' ? BAD : GOOD
1170 const time = clockTime((live ? Math.max(clk.now, hp.startedAt) : hp.endedAt) - hp.startedAt)
1171 const meta = [hp.hasUsage ? `${tokenText(hp.tokens + hp.turnTokens)} tokens` : '', time].filter(Boolean).join(' · ')
1172 const who = [hp.type, hp.model, hp.effort].filter(Boolean).join(' · ')
1173 const current = hp.steps[hp.steps.length - 1]
1174 const spin = SPIN[clk.tick % SPIN.length]!
1175 const pulse = PULSE[clk.tick % PULSE.length]!
1176 const lead = live
1177 ? `Step ${hp.count} · ${current?.live ?? 'Getting started'}`
1178 : hp.phase === 'done'
1179 ? `Done · ${hp.count} step${hp.count === 1 ? '' : 's'}`
1180 : `Stopped before it finished · ${hp.count} step${hp.count === 1 ? '' : 's'}`
1181 // Room for the spinner and pulse on a live line.
1182 const leadW = Math.max(6, cw - (live ? 2 + pulse.length + 1 : 0))
1183 const history = (live ? hp.steps.slice(0, -1) : hp.steps).slice(-HISTORY)
1184 const [b1, b2, b3] = barParts(hp.phase, cw, clk.tick, hp.seat)
1185 const barColor = hp.phase === 'failed' ? BAD : hue.color
1186 const rowW = cw - TAG_W
1187 // About 6.5 CSS pixels a column, as the pet's 48 pixels fill its 8.
1188 const barPx = Math.max(60, Math.floor(cw * 6.5))
1189
1190 return (
1191 <Box
1192 key={`card-${hp.id}`}
1193 flexDirection="row"
1194 width={width}
1195 marginBottom={1}
1196 borderStyle="round"
1197 borderColor={live ? hue.color : undefined}
1198 borderDimColor={!live}
1199 paddingX={1}
1200 paddingY={1}hooks/connectors.ts 117 lines1// Names an MCP connector the same way on every account.
2// A server with a readable name (mcp__notion__..., mcp__plugin_x_fireflies__...) is named for it.
3// A claude.ai connector shows a random id as its server (mcp__<random id>__<tool>), and that id differs per
4// account, so it is named by its tool names instead. Anything else gets a tidy label, never the raw id.
5
6/** Readable server names, keyed in lower case. */
7const NAMED: Record<string, string> = {
8 'google-workspace': 'Google Workspace',
9 gmail: 'Gmail',
10 'google-calendar': 'Google Calendar',
11 'google-drive': 'Google Drive',
12 'chat-relay': 'Chat Relay',
13 handoff: 'Handoff',
14 vidiq: 'vidIQ',
15 apify: 'Apify',
16 'claude-in-chrome': 'Chrome',
17 claude_browser: 'Browser',
18 fireflies: 'Fireflies',
19 posthog: 'PostHog',
20 pipedrive: 'Pipedrive',
21 pandadoc: 'PandaDoc',
22 firecrawl: 'Firecrawl',
23 attio: 'Attio',
24 composio: 'Composio',
25 figma: 'Figma',
26 stripe: 'Stripe',
27 shopify: 'Shopify',
28 notion: 'Notion',
29 slack: 'Slack',
30 calendly: 'Calendly',
31 youtube: 'YouTube',
32 kit: 'Kit',
33 circle: 'Circle',
34 'circle-community': 'Circle',
35 supabase: 'Supabase',
36 vercel: 'Vercel',
37 make: 'Make',
38 higgsfield: 'Higgsfield',
39 github: 'GitHub',
40 linear: 'Linear',
41 hubspot: 'HubSpot',
42 reddit: 'Reddit',
43}
44
45/** Tool name patterns, first match wins. Prefixed families first, then the exact names one app uses. */
46const BY_TOOL: [RegExp, string][] = [
47 [/^fireflies_/i, 'Fireflies'],
48 [/^notion-/i, 'Notion'],
49 [/^vidiq_/i, 'vidIQ'],
50 [/^slack_/i, 'Slack'],
51 [/^composio_/i, 'Composio'],
52 [/^(ads_studio|ai_influencer|scene_builder_3d|shorts_studio|tiktok)_/i, 'Higgsfield'],
53 [/^ads_/i, 'Meta Ads'],
54 [/^(availability|event_types|meetings|routing_forms|scheduling_links|shares|locations|users|organizations)-|calendly/i, 'Calendly'],
55 [
56 /^(search_threads|get_thread|get_message|list_drafts|get_draft|create_draft|update_draft|delete_draft|list_labels|create_label|update_label|delete_label|(un)?label_(thread|message)|(un)?trash_(thread|message)|(un)?mark_(thread|message)_spam|apply_sensitive_(thread|message)_label|update_message_labels|forward|reply)$/i,
57 'Gmail',
58 ],
59 [/^(list_calendars|respond_to_event|suggest_time|search_events)$/i, 'Google Calendar'],
60 [
61 /^(list_recent_files|search_files|read_file_content|download_file_content|get_file_metadata|get_file_permissions|share_file|trash_file|copy_file)$/i,
62 'Google Drive',
63 ],
64 [/^(get_spreadsheet|get_values|update_values|append_values|batch_clear_values|update_formulas|insert_dimension|update_spreadsheet|copy_sheet_to_another_spreadsheet)$/i, 'Google Sheets'],
65 [/(subscriber|broadcast|sequence|landing_page|snippet|growth_stats|email_template|creator_profile|email_stats)/i, 'Kit'],
66 [/(community_member|space_group|(^|_)spaces?(_|$)|space_member|course_(lesson|section)|access_group|member_tag|tagged_member|live_room|gamification|paywall|flagged_content|filter_kit|post_summary|advanced_search)/i, 'Circle'],
67 [/(^|-)actors?(-|$)|apify|(^|-)dataset(-|$)|key-value-store/i, 'Apify'],
68 [/-records?(-|$)|^semantic-search-|call-recording|^run-basic-report$|-workspace-(members|teams)$|-list-entry-|attribute-definitions$/i, 'Attio'],
69 [/^(scenarios?|scenario-[a-z-]+|data-stores?|data-store-records|data-structures?|hooks|hook-[a-z-]+|executions|credential-requests|app-modules?|connections|connection-[a-z-]+)_/i, 'Make'],
70 [/^(apply_migration|execute_sql|list_migrations|list_tables|list_extensions|generate_typescript_types|get_advisors|get_publishable_keys|get_project_url|deploy_edge_function|list_edge_functions|get_edge_function|create_edge_function_secret)$/i, 'Supabase'],
71 [/deployment|vercel|edge_config|project_domain|sandboxes|named_sandbox|toolbar_|rolling_release|_drains?$|kms_/i, 'Vercel'],
72 [/^(get_design_context|get_figjam|use_figma|get_figma_skill|get_variable_defs|create_design_system_rules|search_design_system|send_code_to_figma|add_code_connect_map|get_motion_context)$|code_connect|shader|generative_plugin|^weave_/i, 'Figma'],
73 [/^(find_(person|company|decision_maker)_emails?|verify_email|report_bad_email)$/i, 'AnyMailFinder'],
74 [/^(match|enrich)-(business|prospects)$|^fetch-(entities|businesses-events|prospects-events|entities-statistics)$/i, 'Vibe Prospecting'],
75 [/^(getTranscripts|getVideoDetails|searchVideos|getChannelStatistics|getVideoComments|getTrendingVideos|getChannelTopVideos|getVideoCategories)$/, 'YouTube'],
76]
77
78/** The label for a connector whose name cannot be told from its server or its tools. */
79export const OTHER = 'Connector'
80
81/** Every name this file can give, so a caller can set up one handler per name. */
82export const CONNECTOR_NAMES: readonly string[] = [...new Set([...Object.values(NAMED), ...BY_TOOL.map(r => r[1]), OTHER])]
83
84const titleCase = (s: string) =>
85 s
86 .split(/[-_\s]+/)
87 .filter(Boolean)
88 .map(w => w[0]!.toUpperCase() + w.slice(1))
89 .join(' ')
90
91/** A random id rather than a name: 8 hex characters, a dash, then more hex and dashes. */
92export const isIdServer = (server: string) => /^[0-9a-f]{8}-[0-9a-f-]*$/i.test(server)
93
94// Servers already named by one of their tools, so a tool that fits no pattern still gets the name.
95const known = new Map<string, string>()
96
97/** The server and tool parts of an MCP tool name: mcp__<server>__<tool>. */
98export function splitTool(tool: string): { server: string; name: string } {
99 const [, server = '', ...rest] = tool.split('__')
100 return { server, name: rest.join('__') }
101}
102
103/** The friendly name for an MCP server, given one of its tool names. */
104export function connectorName(server: string, toolName: string): string {
105 if (!isIdServer(server)) {
106 let name = server.replace(/^plugin_[^_]+_/, '').replace(/^claude_ai_/i, '')
107 if (!name) name = server
108 return NAMED[name.toLowerCase()] ?? titleCase(name)
109 }
110 const hit = BY_TOOL.find(([re]) => re.test(toolName))
111 if (hit) {
112 known.set(server, hit[1])
113 return hit[1]
114 }
115 return known.get(server) ?? OTHER
116}
117types/index.d.ts 75 lines1export type HelperPhase = 'working' | 'done' | 'failed'
2
3export type StepKind = 'Command' | 'Connector' | 'File read' | 'File write' | 'Skill' | 'Web' | 'Subagent' | 'Tool'
4
5export type Step = {
6 kind: StepKind
7 /** Short label for the step list: "Gmail · search threads", "pre-outline.md". */
8 label: string
9 /** Dim text after the label: a file's folder. Empty when there is none. */
10 dim: string
11 /** The same step in plain words for the live line: "Reading pre-outline.md". */
12 live: string
13}
14
15export type Helper = {
16 /** The id `agent.spawn` answered; tool calls and turns of its loop carry it. */
17 id: string
18 description: string
19 /** The agent type: general-purpose, Explore, a plugin's agent. */
20 type: string
21 /** Display model ("Opus 5.5"), empty when not known yet. */
22 model: string
23 /** Effort the engine asked for on its last step, empty when not known. */
24 effort: string
25 phase: HelperPhase
26 /** Clock time it started and, once over, when it ended. */
27 startedAt: number
28 endedAt: number
29 /** How many tool calls it made in all. */
30 count: number
31 /** Its last 200 steps, oldest first; the last one is the live step. */
32 steps: Step[]
33 /** Tokens from finished turns, plus the live turn's steps so far. */
34 tokens: number
35 turnTokens: number
36 /** True once the engine reported any usage for it. */
37 hasUsage: boolean
38 /** Position in the desk, in start order. */
39 seat: number
40 /** Which of the 8 body colours its pet wears; running helpers never share one while there are 8 or fewer. */
41 hue: number
42}
43
44export type PlanStatus = 'pending' | 'in_progress' | 'completed'
45
46/** What the plan band draws: the chat's plan, and whether the band shows it now. */
47export type BandPlan = {
48 title: string
49 steps: { text: string; status: PlanStatus }[]
50 /** False once a finished plan has held at 100% for 20 s, or for an empty list. */
51 shown: boolean
52}
53
54declare module 'claude-code' {
55 interface PluginState {
56 'helper-desk': {
57 helpers: Helper[]
58 /** Tick count (spinner and pet frames) and the clock time of the last tick, for elapsed times. */
59 clock: { tick: number; now: number }
60 /** Which cards show their full step list, and which groups are folded. */
61 view: { open: string[]; folded: string[] }
62 /** The chat's /cost total in US dollars, null where the host keeps none. */
63 cost: { usd: number | null }
64 /** True while switches.json turns the mod off. */
65 off: boolean
66 /** True while switches.json hides the band and the pane ("helper-desk:hidden"). */
67 hidden: boolean
68 /** The plan the band draws; null before the first plan. */
69 plan: BandPlan | null
70 /** Shimmer tick for the step in progress; it counts only while one is in progress. */
71 shimmer: number
72 }
73 }
74}
75