SLOPSHOPPER

helper-desk

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · helper-desk
│ ┃ Helper Desk ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cards show up here when Claude starts a suba ⎿ 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 │ │ › /helpers │ ⎿ helper-desk: Helper Desk opened. Cards show up when Claude start │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Helper Desk
No subagents yet [ Hide ] Cards show up here when Claude starts a subagent.
README

anirach.com

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.


At a glance

PagesHome · Tutorials (/blog/) · Thoughts (/thoughts/) · Publications · Books · Projects & Apps · News & Updates
Posts135, in 10 series across two catalogs, every one bilingual Thai/English
Files147 HTML files on disk (146 enumerated by the linter; 404.html is deliberately excluded)
StackPlain HTML5 + CSS3, zero JavaScript. Google Fonts is the only external dependency
HostingGitHub Pages (classic Jekyll build) on the custom domain anirach.com, fronted by Cloudflare
Build stepNone
TestsFour scripts, run before every push — check_site.py alone carries 62 cross-file integrity checks. This is the test suite

The seven pages

PageWhat it holds
index.htmlSingle-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.

The two catalogs

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

SeriesPostsNavigation
⧉ AI-Core Functional Workflows12generated12-chip strip · GitHub code, tests and exercises
🛡️ Engineering AI-Core Systems10chip strip · generated
💻 Hermes Desktop Hands-On7chip strip · generated
🧭 AI Transformation for Organizations20grouped chip strip (Reframe · Redesign · Engineer · Lead) · generated
⚡ Hermes Agent in Practice10chip strip
🤖 OpenClaw for Organizations13seven are a numbered chip-strip series; six stand alone with no nav
🔧 DevOps & Vibe Coding24a single prev/next chain

Thoughts — /thoughts/, hero reads 3 Series · 39 Essays

SeriesEssaysNavigation
🌱 Wisdom for a Good Life20grouped chip strip, four arcs of five (Self · Mind · Work · Others) · generated
🧭 Working Philosophies10chip strip, one working week Monday to Friday · generated
🌅 Life Thought & Philosophy9chip 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.

Repository structure

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

Architecture: every page is an island

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.

Local development

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.server does not and returns 404. That is not a regression.

Verification

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.

Deployment

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=$RANDOM to 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.

Performance notes

Two measured costs were paid down on 2026-09-10, and both are easy to reintroduce:

  • **Fonts are requested with the variable range syntax. 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.
  • Catalog cards ship a 144px derivative through 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).

Adding a new blog post

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.

Maintenance

Four skills in .claude/skills/ carry the verified detail:

SkillCovers
page-designThe house visual system — canonical tokens, type scale, component vocabulary, approved hero gradients, and the anti-patterns this repo has been burned by
blog-postAdding, editing or removing a post without breaking the hand-wired navigation; plus the Thai essay voice brief
a11y-perfAccessibility and performance rules with this site's real measured numbers
site-checkThe 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.

Contact

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.

AI-Core Functional Workflows

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.

Source 3 files
hooks/register.tsx 1417 lines
1import 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 lines
1// 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}
117
types/index.d.ts 75 lines
1export 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