Helper Desk: every task listed above the prompt, the ones in progress flashing, and a side pane with one live card per subagent

Mods for Claude Code.
| Mod | What it does |
|---|---|
| cache-bar | Status-line bar under your existing status line showing how long the prompt cache stays warm, with a handoff countdown: cache ▰▰▰▱▱▱▱▱ 23m 182k · handoff in 18m. Turns yellow at a quarter of the TTL, red at the handoff threshold, and follows the drop to a 5-minute TTL. /cache-bar:setup installs it. |
| filetree | Fork of data-goblin/claude-code-filetree that shows only the files Claude touched this session; the header's ◆ button toggles to the whole tree. |
| helper-desk | Task list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent. |
| helpin-per | Pane listing open Helpin PER-* tasks; pick one to have Claude start working on it. |
| cache-clock | Countdown above the prompt until the chat's cache goes cold, with a Keep warm button. |
| handoff | When a chat gets very long, writes a handoff prompt and starts a fresh chat with it (/refresh <focus>; the handoff tells the new chat to run /pickup). |
| reflect | When you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder. |
| chat-inventory | Side pane, In this chat, listing the to-do list, connectors and calls per connector, files and skills this chat used. |
| screen-share | /screen-share hides names, emails, phone numbers and money amounts on screen while you record. |
| replay-theater | /replay steps through the file edits Claude made in the last turn, one diff at a time. |
| mods | /mods opens a pane to turn each mod on or off, including mods installed from other marketplaces, and to install mods a marketplace offers that are not installed yet. |
| effort-optimizer | Before Claude starts on a prompt, a quick Sonnet check picks the effort level it needs (Low to Max) and offers a one-click switch, or sets it by itself with /effort-optimizer auto. |
| prompt-refiner | A Refine button in the hint line under the prompt, any time in the chat. Press it and pick a rewrite style for the prompt in the box, like a phone keyboard's suggestions: Clearer, Shorter, Elaborate (fuller, same shape), Structured (the job, the why, the guardrails, done means), Ask me first (up to 3 questions on gaps), or My guidance. The rewrite shows above the prompt with what changed; Use it fills the box, Refine runs another pass. /refine <prompt> does the same. |
The eight mods other than replay-theater, from helper-desk to effort-optimizer, come from Ben AI's benai-mods (benai.co) and are redistributed with permission, with Windows fixes added here. replay-theater (one Ben AI change: /mods switch support) is Anthropic's sample mod from anthropics/claude-code-playground, under the Apache 2.0 license in its LICENSE.
Reflect saves a rule either to ~/.claude/CLAUDE.md (Save everywhere) or as its own note in a folder you choose (Save to vault). Set that folder as rules_folder when you install reflect, or later for reflect in /config. If it is empty, reflect asks for the folder the first time you press Save to vault.
At the prompt of a terminal session:
/plugin install helper-desk --marketplace jz-wilson/claude-mods
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install helper-desk --marketplace jz-wilson/claude-mods
claude plugin install cache-bar --marketplace jz-wilson/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with jz-wilson/claude-mods, then Add plugin.
cache-bar is a status-line script, so it takes over statusLine and runs the previous status line inside it. After installing, run /cache-bar:setup. It:
settings.json to settings.json.bak-cache-bar;statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;statusLine.command at the newest installed cache-bar version, and sets refreshInterval: 60 so the countdown repaints while you are idle.The previous status line prints first, unchanged, and the bar goes on its own line underneath. If the inner command fails, the bar still prints. Before the first request of a session there is no cache yet, so the bar is hidden; after expiry it shows a dim cold.
The same config file sets the handoff threshold and the context floor:
{ "inner": "…", "handoffMinutes": 5, "floorTokens": 80000 }
The threshold is capped at a fifth of the TTL, so it fits the 5-minute TTL too. Under floorTokens of context the bar says no handoff (<floor).
To undo, copy settings.json.bak-cache-bar back over settings.json, or set statusLine.command back to the inner value.
Each mod is a folder with .claude-plugin/plugin.json. CI runs claude plugin validate on every mod for each push and pull request, then claude plugin test for mods with a hooks module and bun test for the rest. A merge to main that bumps a mod's version publishes a GitHub release tagged <mod>-v<version>.
claude plugin validate agent-tree
claude plugin test agent-tree
bun test cache-barhooks/register.tsx 2336 lines1import type { EngineInterface, Register, ThemeKey } from 'claude-code'
2
3import type { BandPlan, Helper, HelperPhase, JevCall, 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
18const JEV = { plugin: 'helper-desk', key: 'jev' } as const
19const JEV_TOKENS = { plugin: 'helper-desk', key: 'jevTokens' } as const
20const FOCUS = { plugin: 'helper-desk', key: 'focus' } as const
21const MOTION = { plugin: 'helper-desk', key: 'motion' } as const
22
23const TICK_MS = 300
24const POLL_MS = 2000
25const SWITCH_MS = 3000
26const HISTORY = 3
27const MAX_STEPS = 200
28const TAG_W = 11
29// The Svg draws at 3 pixels a grid pixel from this body width up and 2 below it (whole numbers, so the art stays crisp); the Raster is always 1 to 1.
30const PET_WIDE_FROM = 80
31// The least text columns a sprite may leave beside it.
32const MIN_TEXT = 16
33// One cell of gap between the pet and the card text.
34const PET_GAP = 1
35// One timer repaints every running terminal pet.
36const FRAME_MS = 40
37const MOTION_KEY = 'motion'
38// How long a card stays lit after a peer button scrolls to it.
39const FOCUS_MS = 2000
40// Jev's live log, written by the jev-lab mod.
41const JEV_GRACE_MS = 6000
42
43// Functional colour follows the person's theme. Only the Svg drawings below keep hex.
44const ACCENT: ThemeKey = 'claude'
45const GOOD: ThemeKey = 'success'
46const BAD: ThemeKey = 'error'
47const WARN: ThemeKey = 'warning'
48// Text on a filled group heading.
49const INK: ThemeKey = 'inverseText'
50// The task list is purple (the theme's "merged"): the step in progress flashes between bright and dim.
51const PURPLE: ThemeKey = 'merged'
52// The red inside the Svg bar of a stopped card.
53const SVG_BAD = '#dc2626'
54// Quarter circles read clearly on desktop fonts and in the terminal.
55const SPIN = ['◐', '◓', '◑', '◒']
56const PULSE = ['●∙∙', '∙●∙', '∙∙●', '∙●∙']
57
58// The badge letter by model.
59const TIER_KEY: Record<'haiku' | 'sonnet' | 'opus', ThemeKey> = { haiku: 'success', sonnet: 'suggestion', opus: 'claude' }
60
61const KIND_COLOR: Record<StepKind, ThemeKey> = {
62 Command: 'warning',
63 Connector: 'suggestion',
64 'File read': 'success',
65 'File write': 'claude',
66 Skill: 'merged',
67 Web: 'planMode',
68 Subagent: 'permission',
69 Tool: 'subtle',
70}
71
72// ---------- Pets: a pixel Claude crab, one body colour per helper ----------
73
74type Px = [x: number, y: number]
75
76// Eight body colours that are easy to tell apart and read on light and dark. `color` paints the
77// Svg pet and bar (which carry their own outline); `key` is the nearest theme colour, for the
78// terminal glyph pet, bar and card border.
79const HUES: { name: string; color: string; key: ThemeKey }[] = [
80 { name: 'red', color: '#dc2626', key: 'error' },
81 { name: 'orange', color: '#ea580c', key: 'claude' },
82 { name: 'yellow', color: '#eab308', key: 'warning' },
83 { name: 'green', color: '#16a34a', key: 'success' },
84 { name: 'teal', color: '#0d9488', key: 'planMode' },
85 { name: 'blue', color: '#2563eb', key: 'suggestion' },
86 { name: 'purple', color: '#9333ea', key: 'merged' },
87 { name: 'pink', color: '#db2777', key: 'diffRemovedWord' },
88]
89const hueOf = (hue: number) => HUES[((hue % HUES.length) + HUES.length) % HUES.length]!
90
91// ---------- Sprites: the model crabs and Jev's cube-bot, ported from the approved mock ----------
92
93// Eyes and outline are the same on every pet.
94const EYE = '#2b1d16'
95const INK_HEX = '#2b1d16'
96
97type Tier = 'haiku' | 'sonnet' | 'opus'
98type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
99
100// The crab's grid, and the shell by model: size tells the model.
101const GRID_W = 24
102const GRID_H = 18
103const SHELL: Record<Tier, { w: number; h: number }> = { haiku: { w: 8, h: 5 }, sonnet: { w: 10, h: 7 }, opus: { w: 12, h: 8 } }
104// One leg step in ms by effort; the four steps of the shuffle make the period.
105const LEG_MS: Record<Effort, number> = { low: 200, medium: 140, high: 110, xhigh: 90, max: 75 }
106const DOTS: Record<Effort, number> = { low: 1, medium: 2, high: 3, xhigh: 4, max: 5 }
107const EFFORTS = Object.keys(LEG_MS) as Effort[]
108const DELTA = [[0, 0, 0, 0], [-1, 0, 1, 0], [-1, -1, 1, 1], [0, -1, 0, 1]]
109const BOB_MS = 1400
110const CLAW_MS = 2300
111const BLINK_MS = 3300
112const BLINK_HOLD_MS = 150
113const STEAM_MS = 900
114// Each pet starts this much later than the one before it in HUES order, so crabs don't move in lockstep.
115const PHASE_MS = 370
116
117const wrap = (n: number, m: number) => ((n % m) + m) % m
118const phaseOf = (hue: number) => (((hue % HUES.length) + HUES.length) % HUES.length) * PHASE_MS
119
120/** The model family of a display name ("Opus 5.5"); a model that is not known draws as sonnet. */
121export function tierOf(model: string): Tier {
122 const m = /(opus|sonnet|haiku)/i.exec(model)
123 return m ? (m[1]!.toLowerCase() as Tier) : 'sonnet'
124}
125// An integer budget has no scale in the API's types, so it draws as medium, like an effort nobody named.
126const effortOf = (v: unknown): Effort => {
127 const name = typeof v === 'string' ? v.trim().toLowerCase() : ''
128 return EFFORTS.includes(name as Effort) ? (name as Effort) : 'medium'
129}
130const dotsOf = (e: Effort) => '●'.repeat(DOTS[e]) + '○'.repeat(5 - DOTS[e])
131
132function shade(hex: string, k: number) {
133 const n = Number.parseInt(hex.slice(1), 16)
134 const ch = (s: number) => {
135 const v = (n >> s) & 255
136 return Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k)
137 }
138 return `#${[16, 8, 0].map(s => ch(s).toString(16).padStart(2, '0')).join('')}`
139}
140
141/** A sprite: "x,y" to a colour code, and the code's colour. */
142type Sprite = { px: Map<string, string>; colors: Record<string, string> }
143type Placed = { sprite: Sprite; ox: number; oy: number }
144
145type Pose = { u: number; legs: number; up: boolean; blink: boolean; hop: number }
146const STILL: Pose = { u: 0, legs: 0, up: false, blink: false, hop: 0 }
147
148/** The pose at `t` ms: an eased sine bob, a four-phase leg shuffle at the effort's pace, claws on their own period, a blink now and then. */
149export function poseAt(t: number, phase: number, effort: Effort): Pose {
150 const u = t + phase
151 const leg = LEG_MS[effort]
152 const wave = 0.5 - 0.5 * Math.cos((2 * Math.PI * u) / BOB_MS)
153 const f = wrap(u, CLAW_MS) / CLAW_MS
154 return {
155 u,
156 legs: Math.floor(wrap(u, leg * 4) / leg),
157 up: f < 0.1 || (f > 0.18 && f < 0.28),
158 blink: wrap(u, BLINK_MS) < BLINK_HOLD_MS,
159 hop: Math.round(wave * wave * (3 - 2 * wave)),
160 }
161}
162
163type PxMap = Map<string, string>
164type CrabParts = { shell: PxMap; arms: PxMap; legs: PxMap; props: PxMap; armProps: PxMap }
165const crabGeo = (tier: Tier) => {
166 const { w, h } = SHELL[tier]
167 const x0 = (GRID_W - w) >> 1
168 return { w, h, x0, top: 14 - h, cx: x0 + (w >> 1) }
169}
170
171/** The crab's pieces for one pose, `dy` rows down; the Svg pet and the Raster pet both draw from these. */
172function crabParts(tier: Tier, up: boolean, legs: number, dy: number): CrabParts {
173 const { w, x0, top, cx } = crabGeo(tier)
174 const out: CrabParts = { shell: new Map(), arms: new Map(), legs: new Map(), props: new Map(), armProps: new Map() }
175 const set = (m: PxMap, x: number, y: number, c: string) => {
176 const yy = y + dy
177 if (x >= 0 && x < GRID_W && yy >= 0 && yy < GRID_H) m.set(`${x},${yy}`, c)
178 }
179 for (let y = top; y <= 13; y += 1) for (let x = x0; x < x0 + w; x += 1) set(out.shell, x, y, 'B')
180 if (tier === 'sonnet') for (let x = x0 + 1; x < x0 + w - 1; x += 1) set(out.shell, x, 11, 'D')
181 if (tier === 'opus') {
182 for (let x = x0; x < x0 + w; x += 1) set(out.shell, x, 13, 'D')
183 for (const [x, y] of [[x0 + 3, top + 5], [x0 + w - 4, top + 5], [cx, top + 4]] as const) set(out.shell, x, y, 'L')
184 }
185 const armY = up ? top : top + 2
186 for (const ax of [x0 - 2, x0 - 1, x0 + w, x0 + w + 1]) {
187 set(out.arms, ax, armY, 'B')
188 set(out.arms, ax, armY + 1, 'B')
189 }
190 const step = DELTA[legs % 4]!
191 ;[x0 + 1, x0 + 3, x0 + w - 4, x0 + w - 2].forEach((bx, i) => {
192 set(out.legs, bx + step[i]!, 14, 'B')
193 set(out.legs, bx + step[i]!, 15, 'B')
194 })
195 if (tier === 'haiku') {
196 set(out.props, cx, top - 1, 'g')
197 set(out.props, cx, top - 2, 'g')
198 for (const [x, y] of [[cx - 1, top - 2], [cx - 2, top - 3], [cx + 1, top - 3]] as const) set(out.props, x, y, 'G')
199 }
200 if (tier === 'sonnet') {
201 const ax = x0 + w + 1
202 set(out.armProps, ax + 1, armY - 1, 'N')
203 for (const [x, y] of [[ax + 2, armY - 2], [ax + 3, armY - 3], [ax + 4, armY - 4], [ax + 2, armY - 3], [ax + 3, armY - 4]] as const) set(out.armProps, x, y, 'Q')
204 }
205 if (tier === 'opus') {
206 for (let x = x0 + 3; x <= x0 + w - 4; x += 1) set(out.props, x, top - 1, 'C')
207 for (const x of [x0 + 3, cx - 1, cx, x0 + w - 4]) set(out.props, x, top - 2, 'C')
208 }
209 return out
210}
211
212// Codes: B body, D shade, L spot, E eye, G leaf, g stem, Q quill, N nib, C crown, S steam, O outline.
213const crabColors = (body: string): Record<string, string> => ({
214 B: body, D: shade(body, -0.32), L: shade(body, 0.45), E: EYE, O: INK_HEX, G: '#4ade80', g: '#15803d', Q: '#f5efe0', N: INK_HEX, C: '#f2c230', S: '#b8c2cf',
215})
216const steamAt = (tier: Tier, k: number): [number, number][] => {
217 const { x0, top } = crabGeo(tier)
218 return [[x0 - 1, top - 1 - k], [x0 - 2, top - 2 - k], [x0, top - 3 - k]]
219}
220const eyeXs = (tier: Tier) => {
221 const { x0, w } = crabGeo(tier)
222 return [x0 + 2, x0 + w - 3]
223}
224
225/** One crab, as the mock draws it. `still` is the standing pose with no steam. */
226export function crabSprite(tier: Tier, t: number, o: { effort?: Effort; still?: boolean; phase?: number; body?: string } = {}): Sprite {
227 const effort = o.effort ?? 'medium'
228 const p = o.still ? STILL : poseAt(t, o.phase ?? 0, effort)
229 const parts = crabParts(tier, p.up, p.legs, -p.hop)
230 const px: PxMap = new Map([...parts.shell, ...parts.arms, ...parts.legs, ...parts.props, ...parts.armProps])
231 for (const k of [...px.keys()]) {
232 const [x, y] = k.split(',').map(Number) as [number, number]
233 for (const [a, b] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] as const) {
234 if (a >= 0 && a < GRID_W && b >= 0 && b < GRID_H && !px.has(`${a},${b}`)) px.set(`${a},${b}`, 'O')
235 }
236 }
237 const { top } = crabGeo(tier)
238 for (const ex of eyeXs(tier)) {
239 px.set(`${ex},${top + 3 - p.hop}`, 'E')
240 if (!p.blink) px.set(`${ex},${top + 2 - p.hop}`, 'E')
241 }
242 if (!o.still && (effort === 'xhigh' || effort === 'max')) {
243 const k = Math.floor(wrap(p.u, STEAM_MS) / 300)
244 for (const [x, y] of steamAt(tier, k)) if (y >= 0 && !px.has(`${x},${y}`)) px.set(`${x},${y}`, 'S')
245 }
246 return { px, colors: crabColors(o.body ?? '#2563eb') }
247}
248
249// Jev on a 14 by 16 grid, built from runs so every row is exactly 14 wide.
250const JEV_H = 16
251const RUNS: [string, number][][] = [
252 [['.', 6], ['o', 2], ['.', 6]], [['.', 4], ['o', 2], ['t', 2], ['o', 2], ['.', 4]], [['.', 2], ['o', 2], ['t', 6], ['o', 2], ['.', 2]],
253 [['o', 2], ['t', 10], ['o', 2]], [['o', 1], ['l', 2], ['t', 8], ['r', 2], ['o', 1]], [['o', 1], ['l', 4], ['t', 4], ['r', 4], ['o', 1]],
254 ...Array.from({ length: 5 }, (): [string, number][] => [['o', 1], ['l', 5], ['o', 2], ['r', 5], ['o', 1]]),
255 [['o', 2], ['l', 4], ['o', 2], ['r', 4], ['o', 2]], [['.', 2], ['o', 2], ['l', 2], ['o', 2], ['r', 2], ['o', 2], ['.', 2]], [['.', 4], ['o', 6], ['.', 4]],
256]
257const CUBE = RUNS.map(r => r.map(([c, n]) => c.repeat(n)).join(''))
258// The bars on the cube's right face: one for haiku, two for sonnet, three for opus.
259const BARS: [number, number[]][] = [[8, [10]], [10, [9, 10]], [12, [8, 9, 10]]]
260const FACES = { t: '#f7a8df', l: '#e052c0', r: '#a8338e' }
261const TIERS: Tier[] = ['haiku', 'sonnet', 'opus']
262// The canvas by what is drawn: Jev alone, or Jev with the judge crab beside it.
263const JEV_CANVAS_W = 20
264const JEV_JUDGE_W = 32
265const CANVAS_H = 18
266type JevState = 'asking' | 'answered' | 'judging' | 'judged'
267type Face = 't' | 'l' | 'r'
268const ROTATIONS: Face[][] = [['t', 'l', 'r'], ['l', 'r', 't'], ['r', 't', 'l']]
269
270type JevParts = { top: PxMap; rest: PxMap; bars: PxMap; eyesLow: PxMap; eyesTop: PxMap; legs: PxMap }
271
272/** Jev's pieces: `dy` rows down, the top rows shifted `tilt` columns, `bars` lit, and leg pair `legLift` (or none, -1) raised. */
273function jevParts(bars: number, dy: number, tilt: number, legLift: number): JevParts {
274 const out: JevParts = { top: new Map(), rest: new Map(), bars: new Map(), eyesLow: new Map(), eyesTop: new Map(), legs: new Map() }
275 CUBE.forEach((row, y) => [...row].forEach((c, x) => {
276 if (c === '.') return
277 ;(y <= 5 ? out.top : out.rest).set(`${x + (y <= 5 ? tilt : 0)},${y + dy + 1}`, c)
278 }))
279 BARS.forEach(([x, ys], i) => ys.forEach(y => out.bars.set(`${x},${y + dy + 1}`, i < bars ? 'k' : 'b')))
280 for (const ex of [2, 4]) {
281 out.eyesLow.set(`${ex},${9 + dy}`, 'e')
282 out.eyesTop.set(`${ex},${8 + dy}`, 'e')
283 }
284 ;[[3, 4], [9, 10]].forEach(([a, b], i) => {
285 for (const x of [a!, b!]) {
286 out.legs.set(`${x},${14 + dy + 1}`, 'f')
287 if (legLift !== i) out.legs.set(`${x},${15 + dy + 1 > JEV_H - 1 ? JEV_H - 1 : 15 + dy + 1}`, 'f')
288 }
289 })
290 return out
291}
292
293/** The codes' colours: the three faces by rotation, the dim bars from the right face. */
294const jevColors = (rot: Face[]): Record<string, string> => {
295 const faces = { t: FACES[rot[0]!], l: FACES[rot[1]!], r: FACES[rot[2]!] }
296 return { ...faces, o: INK_HEX, k: INK_HEX, e: INK_HEX, f: INK_HEX, b: shade(faces.r, -0.25) }
297}
298
299/** Jev in a state, as the mock draws it. Asking tumbles and flickers its bars; answered and judging bob; judging tilts its top. */
300export function jevSprite(state: JevState, tier: Tier | undefined, t: number, o: { phase?: number; still?: boolean; noBlink?: boolean } = {}): Sprite {
301 const u = t + (o.phase ?? 0)
302 let dy = 0
303 let bars = tier ? TIERS.indexOf(tier) + 1 : 0
304 let rot = ROTATIONS[0]!
305 let tilt = 0
306 let legLift = -1
307 if (!o.still) {
308 if (state === 'asking') {
309 const f = Math.floor(u / 120)
310 rot = ROTATIONS[f % 3]!
311 bars = 1 + ((f * 7) % 3)
312 dy = Math.floor(u / 240) % 2 ? -1 : 0
313 legLift = Math.floor(u / 240) % 2
314 } else if (state === 'answered' || state === 'judging') {
315 const wave = 0.5 - 0.5 * Math.cos((2 * Math.PI * u) / BOB_MS)
316 dy = -Math.round(wave * wave * (3 - 2 * wave))
317 if (state === 'judging') tilt = Math.floor(u / 600) % 2 ? 1 : -1
318 }
319 }
320 const parts = jevParts(bars, dy, tilt, legLift)
321 const blink = !o.still && !o.noBlink && state !== 'judged' && state !== 'asking' && wrap(u, BLINK_MS) < BLINK_HOLD_MS
322 const px: PxMap = new Map([...parts.top, ...parts.rest, ...parts.bars, ...parts.eyesLow, ...(blink ? [] : parts.eyesTop), ...parts.legs])
323 return { px, colors: jevColors(rot) }
324}
325
326const ORANGE = '#ea580c'
327/** What the canvas shows for a Jev call: Jev in its state, with the judge crab beside it while judging. */
328function jevPlaced(state: JevState, tier: Tier | undefined, t: number, phase: number, still: boolean): Placed[] {
329 if (state !== 'judging') return [{ sprite: jevSprite(state, tier, t, { phase, still }), ox: 3, oy: 0 }]
330 return [
331 { sprite: jevSprite(state, tier, t, { phase, still }), ox: 1, oy: 0 },
332 { sprite: crabSprite('haiku', t, { effort: 'high', phase: 300, still, body: ORANGE }), ox: 9, oy: 0 },
333 ]
334}
335const jevGridW = (state: JevState) => (state === 'judging' ? JEV_JUDGE_W : JEV_CANVAS_W)
336
337const DEFAULT_COLOR = 0x01000000
338const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
339
340// Uint8Array.toBase64 is outside the es2023 lib a mod's tsconfig names, so encode by hand.
341function toBase64(bytes: Uint8Array): string {
342 let out = ''
343 for (let i = 0; i < bytes.length; i += 3) {
344 const triplet = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
345 out += BASE64[(triplet >> 18) & 0x3f]! + BASE64[(triplet >> 12) & 0x3f]!
346 out += i + 1 < bytes.length ? BASE64[(triplet >> 6) & 0x3f]! : '='
347 out += i + 2 < bytes.length ? BASE64[triplet & 0x3f]! : '='
348 }
349 return out
350}
351
352// The terminal canvases, cropped to the box the art ever touches (every tier, pose and effort for a crab, every
353// state for Jev, with and without the judge crab), so a frame never changes size. Pixel art is drawn one grid pixel
354// to a half-block pixel, never scaled. x0 and y0 are grid pixels, w and h too; a test measures the boxes again.
355type Box = { x0: number; y0: number; w: number; h: number }
356export const CRAB_BOX: Box = { x0: 3, y0: 1, w: 21, h: 16 }
357export const JEV_BOX: Box = { x0: 3, y0: 0, w: 14, h: 16 }
358export const JEV_JUDGE_BOX: Box = { x0: 0, y0: 0, w: 28, h: 17 }
359const jevBox = (state: JevState) => (state === 'judging' ? JEV_JUDGE_BOX : JEV_BOX)
360const cellsOf = (b: Box) => ({ columns: b.w, rows: Math.ceil(b.h / 2) })
361
362/** The crab's canvas in cells. */
363export const petCanvas = () => cellsOf(CRAB_BOX)
364/** Jev's canvas in cells: wider while the judge crab stands beside it. */
365export const jevCanvas = (state: JevState) => cellsOf(jevBox(state))
366
367/** Sprites on a canvas of half-block cells (each two grid pixels tall), one grid pixel to a half-block pixel. */
368function spriteCells(parts: Placed[], box: Box): string {
369 const { columns, rows } = cellsOf(box)
370 const color = new Map<number, number>()
371 for (const { sprite, ox, oy } of parts) {
372 for (const [k, c] of sprite.px) {
373 const [x, y] = k.split(',').map(Number) as [number, number]
374 const hex = sprite.colors[c]
375 if (hex) color.set((y + oy) * 1000 + x + ox, Number.parseInt(hex.slice(1), 16))
376 }
377 }
378 const at = (x: number, y: number) => color.get((box.y0 + y) * 1000 + box.x0 + x) ?? DEFAULT_COLOR
379 // A DataView writes little-endian whatever the platform's byte order, as a Raster requires.
380 const bytes = new Uint8Array(columns * rows * 12)
381 const view = new DataView(bytes.buffer)
382 let offset = 0
383 for (let row = 0; row < rows; row += 1) {
384 for (let col = 0; col < columns; col += 1) {
385 const top = at(col, row * 2)
386 const bottom = row * 2 + 1 < box.h ? at(col, row * 2 + 1) : DEFAULT_COLOR
387 let cell: [number, number, number]
388 if (top === DEFAULT_COLOR && bottom === DEFAULT_COLOR) cell = [0x20, DEFAULT_COLOR, DEFAULT_COLOR]
389 else if (top === DEFAULT_COLOR) cell = [0x2584, bottom, DEFAULT_COLOR]
390 else cell = [0x2580, top, bottom]
391 for (const word of cell) {
392 view.setUint32(offset, word, true)
393 offset += 4
394 }
395 }
396 }
397 return toBase64(bytes)
398}
399
400/** The crab as cells for a Raster or a blit: the pose at `t` ms, its model's art, its body colour. */
401export function drawCrab(t: number, hue: number, o: { tier?: Tier; effort?: Effort; still?: boolean } = {}): string {
402 const sprite = crabSprite(o.tier ?? 'sonnet', t, { effort: o.effort, still: o.still, phase: phaseOf(hue), body: hueOf(hue).color })
403 return spriteCells([{ sprite, ox: 0, oy: 0 }], CRAB_BOX)
404}
405
406/** Jev (with the judge crab beside it while judging) as cells; `phase` keeps one call out of step with another. */
407export function drawJev(state: JevState, tier: Tier | undefined, t: number, phase = 0, still = false): string {
408 return spriteCells(jevPlaced(state, tier, t, phase, still), jevBox(state))
409}
410
411// ---------- The same art as Svg: layers that CSS keyframes move, so a pet moves with no redraws ----------
412
413/** Svg pixels a grid pixel takes, and the pane columns a drawing of `gridW` pixels takes (about 6 pixels a column). */
414export function svgSize(bodyColumns: number, gridW: number) {
415 // Whole pixels a grid pixel, so crispEdges keeps every cell the same size; small beside the text.
416 const px = bodyColumns >= PET_WIDE_FROM ? 2 : 1
417 const w = Math.round(gridW * px)
418 return { width: w, height: Math.round(CANVAS_H * px), columns: Math.ceil(w / 6) }
419}
420
421const cellOf = (k: string) => k.split(',').map(Number) as [number, number]
422
423/** A path of unit squares per colour. */
424function svgPaths(px: PxMap, colors: Record<string, string>): string {
425 const byColor = new Map<string, string>()
426 for (const [k, c] of px) {
427 const [x, y] = cellOf(k)
428 const hex = colors[c]
429 if (hex) byColor.set(hex, `${byColor.get(hex) ?? ''}M${x} ${y}h1v1h-1z`)
430 }
431 return [...byColor].map(([hex, d]) => `<path fill="${hex}" d="${d}"/>`).join('')
432}
433
434const delayStyle = (seconds: number) => (seconds ? ` style="animation-delay:-${seconds.toFixed(3)}s"` : '')
435
436/** A layer is its pixels with a one-pixel outline round the ones `skip` does not already hold. */
437function svgLayer(px: PxMap, colors: Record<string, string>, skip: Set<string>, cls: string, delay: number): string {
438 const ring: PxMap = new Map()
439 for (const k of px.keys()) {
440 const [x, y] = cellOf(k)
441 for (const [a, b] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] as const) {
442 const n = `${a},${b}`
443 if (a >= 0 && a < GRID_W && b >= 0 && b < GRID_H && !px.has(n) && !skip.has(n)) ring.set(n, 'O')
444 }
445 }
446 return `<g${cls ? ` class="${cls}"` : ''}${delayStyle(delay)}>${svgPaths(ring, colors)}${svgPaths(px, colors)}</g>`
447}
448
449/** The keyframes for a pet whose classes start with `p`: bob, leg phases, claws, blink, steam. */
450function crabCss(p: string, effort: Effort, steam: boolean): string {
451 const legSecs = (LEG_MS[effort] * 4) / 1000
452 const claw = CLAW_MS / 1000
453 return (
454 `.${p}bob{animation:${p}bob ${BOB_MS / 1000}s ease-in-out infinite}` +
455 `@keyframes ${p}bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}` +
456 `.${p}lg{opacity:0;animation:${p}lg ${legSecs}s steps(1,end) infinite}@keyframes ${p}lg{0%{opacity:1}25%{opacity:0}100%{opacity:0}}` +
457 (steam ? `.${p}st{opacity:0;animation:${p}st ${STEAM_MS / 1000}s steps(1,end) infinite}@keyframes ${p}st{0%{opacity:1}33.3%{opacity:0}100%{opacity:0}}` : '') +
458 `.${p}up{opacity:0;animation:${p}up ${claw}s steps(1,end) infinite}@keyframes ${p}up{0%{opacity:1}10%{opacity:0}18%{opacity:1}28%{opacity:0}100%{opacity:0}}` +
459 `.${p}dn{animation:${p}dn ${claw}s steps(1,end) infinite}@keyframes ${p}dn{0%{opacity:0}10%{opacity:1}18%{opacity:0}28%{opacity:1}100%{opacity:1}}` +
460 `.${p}ey{animation:${p}ey ${BLINK_MS / 1000}s steps(1,end) infinite}@keyframes ${p}ey{0%{opacity:0}${((BLINK_HOLD_MS / BLINK_MS) * 100).toFixed(1)}%{opacity:1}100%{opacity:1}}` +
461 `@media (prefers-reduced-motion: reduce){.${p}bob,.${p}lg,.${p}st,.${p}up,.${p}dn,.${p}ey{animation:none}.${p}lg0,.${p}dn,.${p}ey{opacity:1}}`
462 )
463}
464
465/** The crab's drawing on the crab grid, and the CSS that moves it (none for a still pet). Classes start with `p`; `phase` is in seconds. */
466function crabSvgBody(tier: Tier, body: string, effort: Effort, moving: boolean, p: string, phase: number): { css: string; svg: string } {
467 const colors = crabColors(body)
468 const eyeY = crabGeo(tier).top + 2
469 const eyes = (ys: number[], cls = '', delay = 0) =>
470 `<g${cls ? ` class="${cls}"` : ''}${delayStyle(delay)}>${svgPaths(new Map(eyeXs(tier).flatMap(x => ys.map(y => [`${x},${y}`, 'E'] as [string, string]))), colors)}</g>`
471 const down = crabParts(tier, false, 0, 0)
472 if (!moving) {
473 const px: PxMap = new Map([...down.shell, ...down.arms, ...down.legs, ...down.props, ...down.armProps])
474 return { css: '', svg: svgLayer(px, colors, new Set(), '', 0) + eyes([eyeY, eyeY + 1]) }
475 }
476 const up = crabParts(tier, true, 0, 0)
477 const shell: PxMap = new Map([...down.shell, ...down.props])
478 const skip = new Set(shell.keys())
479 const legSecs = (LEG_MS[effort] * 4) / 1000
480 const layers = [
481 svgLayer(shell, colors, new Set(), '', 0),
482 svgLayer(new Map([...down.arms, ...down.armProps]), colors, skip, `${p}dn`, phase),
483 svgLayer(new Map([...up.arms, ...up.armProps]), colors, skip, `${p}up`, phase),
484 ...[0, 1, 2, 3].map(n => svgLayer(crabParts(tier, false, n, 0).legs, colors, skip, `${p}lg ${p}lg${n}`, phase + (legSecs * ((4 - n) % 4)) / 4)),
485 eyes([eyeY + 1]),
486 eyes([eyeY], `${p}ey`, phase),
487 ]
488 const steamed = effort === 'xhigh' || effort === 'max'
489 const steam = steamed
490 ? [0, 1, 2]
491 .map(k => {
492 const cells = new Map(steamAt(tier, k).filter(([, y]) => y >= 0).map(([x, y]) => [`${x},${y}`, 'S'] as [string, string]))
493 return `<g class="${p}st"${delayStyle(phase + ((STEAM_MS / 1000) * ((3 - k) % 3)) / 3)}>${svgPaths(cells, colors)}</g>`
494 })
495 .join('')
496 : ''
497 return { css: crabCss(p, effort, steamed), svg: `<g class="${p}bob"${delayStyle(phase)}>${layers.join('')}</g>${steam}` }
498}
499
500const petCache = new Map<string, string>()
501
502/**
503 * The Svg pet. A working one carries its motion as CSS keyframes (bob, four leg phases at the
504 * effort's pace, claws, blink, and steam at xhigh and max), so it moves with no redraws; any
505 * other is the standing pose, with no keyframes.
506 */
507function petSvg(hue: number, moving: boolean, tier: Tier, effort: Effort, bodyColumns: number) {
508 const size = svgSize(bodyColumns, GRID_W)
509 const key = `${hue % HUES.length}:${moving}:${tier}:${effort}:${size.width}`
510 const hit = petCache.get(key)
511 if (hit) return hit
512 const art = crabSvgBody(tier, hueOf(hue).color, effort, moving, '', phaseOf(hue) / 1000)
513 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID_W} ${CANVAS_H}" width="${size.width}" height="${size.height}" shape-rendering="crispEdges">${art.css ? `<style>${art.css}</style>` : ''}${art.svg}</svg>`
514 petCache.set(key, svg)
515 return svg
516}
517
518/**
519 * The Svg for a Jev call. Asking is a flipbook of its twelve frames (its eyes do not blink there);
520 * answered and judging are layers that bob, blink and tilt; any other state is one still frame.
521 */
522function jevSvg(state: JevState, tier: Tier | undefined, moving: boolean, phase: number, bodyColumns: number): string {
523 const gw = jevGridW(state)
524 const size = svgSize(bodyColumns, gw)
525 const open = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${gw} ${CANVAS_H}" width="${size.width}" height="${size.height}" shape-rendering="crispEdges">`
526 const secs = phase / 1000
527 if (!moving) {
528 const drawn = jevPlaced(state, tier, 0, phase, true).map(({ sprite, ox }) => `<g transform="translate(${ox} 0)">${svgPaths(sprite.px, sprite.colors)}</g>`)
529 return `${open}${drawn.join('')}</svg>`
530 }
531 if (state === 'asking') {
532 const frames = Array.from({ length: 12 }, (_, i) => {
533 const sprite = jevSprite('asking', tier, i * 120, { noBlink: true })
534 return `<g class="fb"${delayStyle(secs + (1.44 * ((12 - i) % 12)) / 12)}>${svgPaths(sprite.px, sprite.colors)}</g>`
535 })
536 const css = '.fb{opacity:0;animation:fb 1.44s steps(1,end) infinite}@keyframes fb{0%{opacity:1}8.333%{opacity:0}100%{opacity:0}}@media (prefers-reduced-motion: reduce){.fb{animation:none}.fb:first-child{opacity:1}}'
537 return `${open}<style>${css}</style><g transform="translate(3 0)">${frames.join('')}</g></svg>`
538 }
539 const colors = jevColors(ROTATIONS[0]!)
540 const parts = jevParts(tier ? TIERS.indexOf(tier) + 1 : 0, 0, 0, -1)
541 const group = (px: PxMap, cls = '', delay = 0) => `<g${cls ? ` class="${cls}"` : ''}${delayStyle(delay)}>${svgPaths(px, colors)}</g>`
542 // The lower leg row stays on the ground while the rest of Jev hops.
543 const lower: PxMap = new Map([...parts.legs].filter(([k]) => k.endsWith(`,${JEV_H - 1}`)))
544 const jev =
545 `<g class="jbob"${delayStyle(secs)}>` +
546 group(parts.top, state === 'judging' ? 'jtl' : '', secs) +
547 group(new Map([...parts.rest, ...parts.bars, ...parts.eyesLow, ...parts.legs])) +
548 group(parts.eyesTop, 'jey', secs) +
549 `</g>` +
550 group(lower)
551 const css =
552 `.jbob{animation:jbob ${BOB_MS / 1000}s ease-in-out infinite}@keyframes jbob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1px)}}` +
553 `.jey{animation:jey ${BLINK_MS / 1000}s steps(1,end) infinite}@keyframes jey{0%{opacity:0}${((BLINK_HOLD_MS / BLINK_MS) * 100).toFixed(1)}%{opacity:1}100%{opacity:1}}` +
554 `.jtl{animation:jtl 1.2s steps(1,end) infinite}@keyframes jtl{0%{transform:translateX(-1px)}50%{transform:translateX(1px)}100%{transform:translateX(1px)}}`
555 const reduced = '@media (prefers-reduced-motion: reduce){.jbob,.jey,.jtl{animation:none}.jey{opacity:1}}'
556 if (state === 'answered') return `${open}<style>${css}${reduced}</style><g transform="translate(3 0)">${jev}</g></svg>`
557 const crab = crabSvgBody('haiku', ORANGE, 'high', true, 'c', 0.3)
558 return `${open}<style>${css}${crab.css}${reduced}</style><g transform="translate(1 0)">${jev}</g><g transform="translate(9 0)">${crab.svg}</g></svg>`
559}
560
561// ---------- The desk itself; $.state carries a copy for the pane to draw ----------
562
563let helpers: Helper[] = []
564let seats = 0
565// The next body colour to hand out, in HUES order.
566let nextHue = 0
567let tickN = 0
568let home = ''
569let sessionId = ''
570let off = false
571// "helper-desk:hidden": true in switches.json closes the pane and stops it opening by itself.
572let hidden = false
573let ticker: { cancel: () => void } | null = null
574let poller: { cancel: () => void } | null = null
575// The one timer that repaints every running terminal pet.
576let crabber: { cancel: () => void } | null = null
577let crabBusy = false
578let focusTimer: { cancel: () => void } | null = null
579// False while /helpers motion off holds the pets still; $.store keeps the choice.
580let motion = true
581// The main chat's model, from its own model requests: what a helper that inherits it draws as.
582let mainModel = ''
583// Each pet Raster the last draw mounted, by its key: what the frame timer blits.
584// A Jev entry's columns are the canvas it was mounted with: a call that moves on to another state draws another canvas.
585type PetEntry = { requestId: string; id: string; columns: number; rows: number } & ({ kind: 'crab'; hue: number } | { kind: 'jev' })
586const pets = new Map<string, PetEntry>()
587// Effort asked for in an Agent call, by its tool_use_id, until the spawn picks it up.
588const dispatchEffort = new Map<string, Effort>()
589// Jev's calls as last read from its live file, and whether the poller still watches for changes.
590let jev: JevCall[] = []
591let jevRaw = ''
592// -1 so the first poll always writes, replacing a total kept in state from before a reload.
593let jevTokens = -1
594let jevLive = false
595let jevGraceUntil = 0
596let switcher: { cancel: () => void } | null = null
597const strangers = new Set<string>()
598
599function toolWords(tool: string, connector: string) {
600 const words = tool
601 .replace(/([a-z])([A-Z])/g, '$1 $2')
602 .split(/[-_\s]+/)
603 .filter(Boolean)
604 .map(w => w.toLowerCase())
605 // "slack_search_public" from Slack reads "Slack · search public".
606 if (words.length > 1 && words[0] === connector.split(' ')[0]!.toLowerCase()) words.shift()
607 return words.join(' ')
608}
609
610const base = (p: string) => p.split('/').filter(Boolean).pop() ?? p
611const cut = (t: string, n: number) => (n <= 0 ? '' : t.length > n ? `${t.slice(0, Math.max(0, n - 1))}…` : t)
612const oneLine = (t: string) => t.replace(/\s+/g, ' ').trim()
613
614function folder(p: string) {
615 const parts = p.split('/')
616 parts.pop()
617 const dir = parts.join('/')
618 return home && dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir
619}
620
621/** One tool call: its kind, a short label for the list, and plain words for the live line. */
622function describe(tool: string, args: Record<string, unknown>): Step {
623 const s = (k: string) => (typeof args[k] === 'string' ? oneLine(args[k] as string) : '')
624 const file = (verb: string, kind: StepKind, key = 'file_path'): Step => {
625 const p = typeof args[key] === 'string' ? (args[key] as string) : ''
626 const name = base(p) || 'a file'
627 return { kind, label: name, dim: p ? folder(p) : '', live: `${verb} ${name}` }
628 }
629 if (tool.startsWith('mcp__')) {
630 const { server, name: toolName } = splitTool(tool)
631 const name = connectorName(server, toolName)
632 const words = toolWords(toolName.replace(/__/g, '_'), name)
633 return { kind: 'Connector', label: `${name} · ${words}`, dim: '', live: `${name}: ${words}` }
634 }
635 switch (tool) {
636 case 'Read':
637 return file('Reading', 'File read')
638 case 'Write':
639 return file('Writing', 'File write')
640 case 'Edit':
641 case 'MultiEdit':
642 return file('Editing', 'File write')
643 case 'NotebookEdit':
644 return file('Editing', 'File write', 'notebook_path')
645 case 'Grep':
646 case 'Glob': {
647 const pat = s('pattern')
648 const p = s('path')
649 return {
650 kind: 'File read',
651 label: pat ? `Search "${cut(pat, 60)}"` : 'Search files',
652 dim: p ? (home && p.startsWith(home) ? `~${p.slice(home.length)}` : p) : '',
653 live: pat ? `Searching files for "${cut(pat, 40)}"` : 'Searching files',
654 }
655 }
656 case 'Bash': {
657 const label = s('description') || cut(s('command'), 80) || 'a command'
658 return { kind: 'Command', label, dim: '', live: `Running: ${label}` }
659 }
660 case 'WebSearch':
661 return { kind: 'Web', label: s('query') || 'web search', dim: '', live: `Searching the web: ${s('query')}` }
662 case 'WebFetch': {
663 let host = 'a web page'
664 try {
665 host = new URL(s('url')).host
666 } catch {
667 // Not a URL: keep the plain words.
668 }
669 return { kind: 'Web', label: host, dim: '', live: `Opening ${host}` }
670 }
671 case 'Skill':
672 return { kind: 'Skill', label: s('skill') || 'a skill', dim: '', live: `Using the ${s('skill') || 'a'} skill` }
673 case 'Agent':
674 case 'Task':
675 return {
676 kind: 'Subagent',
677 label: s('description') || 'Start a subagent',
678 dim: '',
679 live: s('description') ? `Starting a subagent: ${s('description')}` : 'Starting a subagent',
680 }
681 case 'SendMessage':
682 return { kind: 'Subagent', label: 'Send a message', dim: '', live: 'Sending a message' }
683 case 'TodoWrite':
684 return { kind: 'Tool', label: 'Update its to-do list', dim: '', live: 'Updating its to-do list' }
685 case 'ToolSearch':
686 return { kind: 'Tool', label: 'Load tools', dim: '', live: 'Loading tools' }
687 default:
688 return { kind: 'Tool', label: tool, dim: '', live: `Using ${tool}` }
689 }
690}
691
692function clockTime(ms: number) {
693 const t = Math.max(0, Math.floor(ms / 1000))
694 const h = Math.floor(t / 3600)
695 const m = Math.floor((t % 3600) / 60)
696 const sec = String(t % 60).padStart(2, '0')
697 return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
698}
699
700function tokenText(n: number) {
701 if (n < 1000) return String(n)
702 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
703 return `${(n / 1_000_000).toFixed(1)}M`
704}
705
706/** "claude-opus-5-5" reads "Opus 5.5"; "haiku" reads "Haiku". */
707function shortModel(model: string) {
708 const hit = /(opus|sonnet|haiku|fable)(?:[-_ ](\d+))?(?:[-_.](\d+))?/i.exec(model)
709 if (!hit) return model
710 const fam = hit[1]![0]!.toUpperCase() + hit[1]!.slice(1).toLowerCase()
711 return hit[2] ? `${fam} ${hit[2]}${hit[3] ? `.${hit[3]}` : ''}` : fam
712}
713
714type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
715const usageTotal = (u: Usage) => u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
716
717const working = () => helpers.filter(hp => hp.phase === 'working').length
718
719/** The next colour in order that no running helper wears; with all 8 taken, the next in order. */
720function pickHue() {
721 const used = new Set(helpers.filter(hp => hp.phase === 'working').map(hp => hp.hue % HUES.length))
722 let hue = nextHue % HUES.length
723 for (let i = 0; i < HUES.length; i += 1) {
724 const h = (nextHue + i) % HUES.length
725 if (!used.has(h)) {
726 hue = h
727 break
728 }
729 }
730 nextHue = hue + 1
731 return hue
732}
733const copy = (hp: Helper): Helper => ({ ...hp, steps: hp.steps.map(st => ({ ...st })), sentTo: [...hp.sentTo], heardFrom: [...hp.heardFrom] })
734
735/**
736 * The person's home folder, where ~/.claude lives: HOME, else USERPROFILE (Windows). Only when neither is set does it
737 * fall back to the folder above ".claude" in the plugin's own path, which is right for an installed copy and
738 * wrong for one loaded with --plugin-dir from elsewhere.
739 */
740async function resolveHome($: EngineInterface) {
741 const fromEnv = (await $.env.get('HOME').catch(() => undefined)) || (await $.env.get('USERPROFILE').catch(() => undefined))
742 if (fromEnv) return fromEnv.replace(/\\/g, '/').replace(/\/$/, '')
743 const guess = $.plugin.root.replace(/\\/g, '/').split('/.claude/')[0] ?? ''
744 home = guess
745 await log($, `no HOME or USERPROFILE: taking ${guess} from the plugin path`)
746 return guess
747}
748
749async function log($: EngineInterface, line: string) {
750 try {
751 const dir = `${home}/.claude/mods-logs`
752 const path = `${dir}/${NAME}.log`
753 let old = ''
754 if (await $.fs.exists(path).catch(() => false)) old = String(await $.fs.read(path))
755 await $.fs.write(path, `${old}${new Date().toISOString()} ${line}\n`)
756 } catch {
757 // Logging must never break the session.
758 }
759}
760
761async function tick($: EngineInterface) {
762 tickN += 1
763 await $.state.set(CLOCK, { tick: tickN, now: await $.clock.now() })
764}
765
766async function readCost($: EngineInterface) {
767 try {
768 const u = await $.session.usage()
769 await $.state.set(COST, { usd: typeof u.cost?.usd === 'number' ? u.cost.usd : null })
770 } catch {
771 // No ledger here: the tile stays hidden.
772 }
773}
774
775const MAIN = 'main'
776const MAX_PEERS = 3
777const jevPath = () => `${home}/.claude/logs/jev-live/${sessionId}.json`
778const jevFinal = (c: JevCall) => c.phase === 'judged' || c.phase === 'skipped'
779const JEV_PHASES: readonly string[] = ['asking', 'answered', 'skipped', 'judging', 'judged']
780
781// jev-lab judges about 90 s after a subagent stops; a call still open after this long is stale, not live.
782const JEV_STALE_MS = 10 * 60_000
783// TypeSafe bills Jev input tokens at $0.042 per million; output is free (typesafe.ai pricing, 2026-10-24).
784const JEV_USD_PER_TOKEN = 0.042 / 1_000_000
785const jevStale = (c: JevCall, now: number) => !jevFinal(c) && now - c.at > JEV_STALE_MS
786
787const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
788const obj = (v: unknown) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined)
789
790/** The calls in jev-lab's file, each rebuilt from the fields that have the right type; a row without an id, a phase or a description is left out. */
791function parseJev(text: string): JevCall[] {
792 const json = JSON.parse(text) as { calls?: unknown }
793 if (!json || !Array.isArray(json.calls)) return []
794 const out: JevCall[] = []
795 for (const raw of json.calls as unknown[]) {
796 const c = obj(raw)
797 const id = str(c?.id)
798 const phase = str(c?.phase)
799 const description = str(c?.description)
800 if (!c || !id || !phase || !JEV_PHASES.includes(phase) || description === undefined) continue
801 const probs = obj(c.probs)
802 const judge = obj(c.judge)
803 const tier = str(c.tier)
804 const call: JevCall = {
805 id,
806 at: typeof c.at === 'number' ? c.at : 0,
807 description,
808 subagentType: str(c.subagentType) ?? '',
809 chosen: str(c.chosen) ?? '',
810 chosenEffort: str(c.chosenEffort) ?? '',
811 phase: phase as JevCall['phase'],
812 }
813 const skipReason = str(c.skipReason)
814 if (skipReason) call.skipReason = skipReason as JevCall['skipReason']
815 if (tier === 'haiku' || tier === 'sonnet' || tier === 'opus') call.tier = tier
816 const effort = str(c.effort)
817 if (effort) call.effort = effort
818 if (probs && ['haiku', 'sonnet', 'opus'].every(k => typeof probs[k] === 'number')) call.probs = probs as JevCall['probs']
819 if (judge && ['tier', 'effort', 'model', 'reason'].every(k => typeof judge[k] === 'string')) call.judge = judge as JevCall['judge']
820 out.push(call)
821 }
822 return out
823}
824
825/**
826 * Reads jev-lab's live file into the drawn copy. A missing file is no cards; a read that fails or
827 * parses badly (the file is rewritten without a swap) keeps what was drawn, so the next poll tries again.
828 * Stops the watch once every call is final or stale.
829 */
830async function pollJev($: EngineInterface) {
831 let calls = jev
832 let tokens = jevTokens
833 // A /clear changes the session id without a session.start, so the live file to read can move.
834 sessionId = await $.session.id().catch(() => sessionId)
835 try {
836 const text = home && sessionId && (await $.fs.exists(jevPath())) ? String(await $.fs.read(jevPath())) : ''
837 calls = text ? parseJev(text) : []
838 const billed = text ? (JSON.parse(text) as { inputTokens?: unknown }).inputTokens : 0
839 tokens = typeof billed === 'number' ? billed : 0
840 } catch {
841 // A torn read: keep the last good copy and the watch.
842 }
843 const now = await $.clock.now()
844 // The mark rides in the drawn copy, so a call going stale redraws its card.
845 const marked: JevCall[] = calls.map(({ stale: _old, ...c }) => (jevStale(c, now) ? { ...c, stale: true } : c))
846 if (tokens !== jevTokens) {
847 jevTokens = tokens
848 await $.state.set(JEV_TOKENS, tokens)
849 }
850 const raw = JSON.stringify(marked)
851 if (raw !== jevRaw) {
852 jevRaw = raw
853 jev = marked
854 await $.state.set(JEV, marked)
855 // A call opening or closing starts or stops the sprites' timer.
856 syncTimers($)
857 }
858 const live = marked.some(c => !jevFinal(c) && !c.stale) || now < jevGraceUntil
859 if (live !== jevLive) {
860 jevLive = live
861 syncTimers($)
862 }
863}
864
865/** A new Agent dispatch: watch the file again. Jev writes its first row a moment after the call, so the watch holds on for a few seconds. */
866async function watchJev($: EngineInterface) {
867 jevGraceUntil = (await $.clock.now()) + JEV_GRACE_MS
868 jevLive = true
869 syncTimers($)
870 await pollJev($)
871}
872
873async function poll($: EngineInterface) {
874 await pollJev($)
875 if (!working()) return
876 const list = await $.agent.list()
877 const now = await $.clock.now()
878 let changed = false
879 for (const a of list) {
880 const hp = helpers.find(x => x.id === a.id)
881 if (!hp || hp.phase !== 'working') continue
882 // The list may lag a turn's end; it only ever moves a card out of working.
883 const next: HelperPhase | null =
884 a.status === 'completed' || a.status === 'idle' ? 'done' : a.status === 'failed' || a.status === 'killed' ? 'failed' : null
885 if (next) {
886 hp.phase = next
887 hp.endedAt = now
888 changed = true
889 }
890 if (!hp.description && a.description) {
891 hp.description = a.description
892 changed = true
893 }
894 }
895 if (changed) await publish($)
896 else await readCost($)
897}
898
899/** A helper's model family: its own model, else the main chat's (a helper that inherits it), else sonnet. */
900const tierOfHelper = (hp: Helper) => tierOf(/(opus|sonnet|haiku)/i.test(hp.model) ? hp.model : mainModel)
901
902// Jev's art moves while a call is asked, answered or being judged, and not once it is stale.
903const jevMoving = (c: JevCall) => !c.stale && (c.phase === 'asking' || c.phase === 'answered' || c.phase === 'judging')
904const jevStateOf = (c: JevCall): JevState => (c.stale || c.phase === 'skipped' || c.phase === 'judged' ? 'judged' : c.phase)
905// Each call keeps its own place in the tumble, so two cards never move in step.
906const jevPhaseOf = (id: string) => [...id].reduce((n, ch) => (n * 31 + ch.charCodeAt(0)) % 1500, 7)
907
908/** Whether a registered pet moves now: its helper works, or its Jev call is open and still drawn on the canvas it was mounted with. */
909function petMoving(pet: PetEntry): boolean {
910 if (pet.kind === 'crab') return helpers.find(x => x.id === pet.id)?.phase === 'working'
911 const c = jev.find(x => x.id === pet.id)
912 return !!c && jevMoving(c) && jevCanvas(jevStateOf(c)).columns === pet.columns
913}
914
915/** The next frame of a moving pet, or null: a pet that stands still, or whose canvas is out of date until the next draw. */
916function petCells(pet: PetEntry, t: number): string | null {
917 if (!petMoving(pet)) return null
918 if (pet.kind === 'crab') {
919 const hp = helpers.find(x => x.id === pet.id)!
920 return drawCrab(t, pet.hue, { tier: tierOfHelper(hp), effort: effortOf(hp.level) })
921 }
922 const c = jev.find(x => x.id === pet.id)!
923 return drawJev(jevStateOf(c), c.tier, t, jevPhaseOf(c.id))
924}
925
926const movingPets = () => [...pets.values()].some(petMoving)
927
928/** One 40 ms frame for every moving pet, crabs and Jev alike; a denied blit means that canvas is gone, so it is not painted again until the next draw. */
929async function crabFrame($: EngineInterface) {
930 if (crabBusy) return
931 crabBusy = true
932 try {
933 const t = await $.clock.now()
934 for (const [key, pet] of [...pets]) {
935 // Motion may turn off, or the timer stop, while an earlier blit is in flight.
936 if (!motion || off || !crabber) break
937 const cells = petCells(pet, t)
938 if (cells === null) continue
939 const { columns, rows } = pet
940 const result = await $.ui.blit({ requestId: pet.requestId, key, cells, columns, rows })
941 if (result.deny !== undefined) pets.delete(key)
942 }
943 } finally {
944 crabBusy = false
945 }
946 syncTimers($)
947}
948
949function syncTimers($: EngineInterface) {
950 if (working() && !off) {
951 ticker ??= $.clock.every(TICK_MS, () => {
952 void tick($).catch(() => undefined)
953 })
954 } else {
955 ticker?.cancel()
956 ticker = null
957 }
958 if ((working() || jevLive) && !off) {
959 poller ??= $.clock.every(POLL_MS, () => {
960 void poll($).catch(err => log($, `poll: ${String(err)}`))
961 })
962 } else {
963 poller?.cancel()
964 poller = null
965 }
966 // Pets move only while a helper works or a Jev call is open, motion is on, and a draw has mounted one.
967 if (motion && !off && movingPets()) {
968 crabber ??= $.clock.every(FRAME_MS, () => {
969 void crabFrame($).catch(err => log($, `crab: ${String(err)}`))
970 })
971 } else {
972 crabber?.cancel()
973 crabber = null
974 }
975}
976
977async function publish($: EngineInterface) {
978 await $.state.set(HELPERS, helpers.map(copy))
979 await $.state.set(CLOCK, { tick: tickN, now: await $.clock.now() })
980 await readCost($)
981 // No status line: on desktop it drew over Context Meter's footer bar. The pane shows the count.
982 $.ui.status(undefined)
983 syncTimers($)
984}
985
986async function addHelper($: EngineInterface, id: string, description: string, type: string, model: string, name = '', level?: Effort) {
987 const known = helpers.find(hp => hp.id === id)
988 if (known) {
989 // Found first through its own tool calls: fill in what the spawn knows.
990 if (description) known.description = description
991 if (type) known.type = type
992 if (model && !known.model) known.model = model
993 if (name && !known.name) known.name = name
994 // A card found first through its own tool calls has no dispatch yet: the spawn tells its effort.
995 if (level) known.level = level
996 await publish($)
997 return
998 }
999 const wasIdle = working() === 0
1000 // Pushed before any await, so a session.send right after the spawn finds it.
1001 const added: Helper = {
1002 id,
1003 description: description || 'Subagent',
1004 type: type || 'subagent',
1005 name,
1006 sentTo: [],
1007 heardFrom: [],
1008 model,
1009 effort: '',
1010 level: level ?? 'medium',
1011 phase: 'working',
1012 startedAt: 0,
1013 endedAt: 0,
1014 count: 0,
1015 steps: [],
1016 tokens: 0,
1017 turnTokens: 0,
1018 hasUsage: false,
1019 seat: seats,
1020 hue: pickHue(),
1021 }
1022 helpers.push(added)
1023 seats += 1
1024 added.startedAt = await $.clock.now()
1025 await publish($)
1026 // The pane opens by itself when a new round of helpers starts.
1027 if (wasIdle && !hidden) void $.ui.open({ id: PANE, title: TITLE }).catch(err => log($, `open: ${String(err)}`))
1028}
1029
1030/** A loop whose spawn this mod missed (a reload, a race): take it from $.agent.list(). */
1031async function adopt($: EngineInterface, id: string) {
1032 if (helpers.some(hp => hp.id === id) || strangers.has(id)) return
1033 const a = (await $.agent.list()).find(x => x.id === id)
1034 if (!a) {
1035 // The engine's own forks carry ids no list names: leave them out.
1036 strangers.add(id)
1037 return
1038 }
1039 // A teammate's address reads <name>@<team>.
1040 await addHelper($, id, a.description, a.type, '', a.teammateId?.split('@')[0] ?? '')
1041}
1042
1043async function noteTool($: EngineInterface, id: string, step: Step) {
1044 if (!helpers.some(x => x.id === id)) await adopt($, id)
1045 const hp = helpers.find(x => x.id === id)
1046 if (!hp) return
1047 hp.count += 1
1048 hp.steps = [...hp.steps, step].slice(-MAX_STEPS)
1049 // A teammate that finished a turn and picks up work again is working again.
1050 if (hp.phase !== 'working') {
1051 hp.phase = 'working'
1052 hp.endedAt = 0
1053 }
1054 await publish($)
1055}
1056
1057async function noteStep($: EngineInterface, id: string, model: string, effort: string, usage: Usage | null) {
1058 if (!helpers.some(x => x.id === id)) await adopt($, id)
1059 const hp = helpers.find(x => x.id === id)
1060 if (!hp) return
1061 if (model) hp.model = shortModel(model)
1062 if (effort) hp.effort = effort
1063 if (usage) {
1064 hp.turnTokens += usageTotal(usage)
1065 hp.hasUsage = true
1066 }
1067 await publish($)
1068}
1069
1070async function finish($: EngineInterface, id: string, phase: HelperPhase, usage: Usage | null) {
1071 const hp = helpers.find(x => x.id === id)
1072 if (!hp) return
1073 // The turn's own sum wins over the steps counted so far.
1074 hp.tokens += usage ? usageTotal(usage) : hp.turnTokens
1075 hp.turnTokens = 0
1076 if (usage) hp.hasUsage = true
1077 hp.phase = phase
1078 hp.endedAt = await $.clock.now()
1079 await publish($)
1080}
1081
1082async function setOff($: EngineInterface, value: boolean) {
1083 if (value === off) return
1084 off = value
1085 await $.state.set(OFF, off)
1086 if (off) {
1087 $.ui.status(undefined)
1088 await $.ui.close({ id: PANE }).catch(() => undefined)
1089 }
1090 syncTimers($)
1091 syncShimmer($)
1092}
1093
1094const HIDE_KEY = `${NAME}:hidden`
1095const switchesPath = () => `${home}/.claude/mods/switches.json`
1096
1097async function readSwitches($: EngineInterface): Promise<Record<string, unknown>> {
1098 if (!(await $.fs.exists(switchesPath()))) return {}
1099 const json = JSON.parse(String(await $.fs.read(switchesPath()))) as unknown
1100 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
1101}
1102
1103async function readSwitch($: EngineInterface) {
1104 try {
1105 const json = await readSwitches($)
1106 const nowHidden = json[`${NAME}:hidden`] === true
1107 if (nowHidden && !hidden) await $.ui.close({ id: PANE }).catch(() => undefined)
1108 if (nowHidden !== hidden) {
1109 hidden = nowHidden
1110 // The band reads this key, so a change redraws it.
1111 await $.state.set(HIDDEN, hidden)
1112 syncShimmer($)
1113 }
1114 await setOff($, json[NAME] === false)
1115 } catch {
1116 // A broken file never turns the mod off.
1117 }
1118}
1119
1120// Read, change, write the whole file, so every chat sees the choice within 3 s.
1121async function setHidden($: EngineInterface, value: boolean) {
1122 try {
1123 const json = await readSwitches($)
1124 if (value) json[HIDE_KEY] = true
1125 else delete json[HIDE_KEY]
1126 await $.fs.write(switchesPath(), `${JSON.stringify(json, null, 2)}\n`)
1127 } catch (err) {
1128 await log($, `save hidden: ${String(err)}`)
1129 }
1130 hidden = value
1131 await $.state.set(HIDDEN, hidden)
1132 syncShimmer($)
1133 if (value) await $.ui.close({ id: PANE }).catch(() => undefined)
1134}
1135
1136/** The helper an address names: its id, its name, or its `<name>@<team>` address. */
1137function helperNamed(address: string) {
1138 const q = address.trim().toLowerCase()
1139 const bare = q.split('@')[0]
1140 const hits = helpers.filter(hp => hp.id.toLowerCase() === q || (!!hp.name && [q, bare].includes(hp.name.toLowerCase())))
1141 // A name can be reused: the one working now, else the newest.
1142 return hits.findLast(hp => hp.phase === 'working') ?? hits[hits.length - 1]
1143}
1144
1145// What the model calls the main chat in a send's `to`; any other address no helper carries is left unrecorded.
1146const MAIN_ADDRESSES = ['main', 'team-lead', 'lead', 'user']
1147
1148const remember = (list: string[], id: string) => [...list.filter(x => x !== id), id].slice(-MAX_PEERS)
1149
1150/** A message left `from` (a helper id, or undefined for the main chat) for `to` (a name or id; the main chat's names count, any other address is not recorded). */
1151async function noteSend($: EngineInterface, from: string | undefined, to: string) {
1152 const sender = from === undefined ? MAIN : helpers.find(hp => hp.id === from)?.id
1153 if (!sender) return
1154 const receiver = helperNamed(to)?.id ?? (MAIN_ADDRESSES.includes(to.trim().toLowerCase()) ? MAIN : undefined)
1155 if (!receiver || sender === receiver) return
1156 const a = helpers.find(hp => hp.id === sender)
1157 const b = helpers.find(hp => hp.id === receiver)
1158 if (a) a.sentTo = remember(a.sentTo, receiver)
1159 if (b) b.heardFrom = remember(b.heardFrom, sender)
1160 if (a || b) await publish($)
1161}
1162
1163/** Scrolls the pane to a helper's card and lights its border for a moment. */
1164async function gotoHelper($: EngineInterface, id: string) {
1165 const target = helpers.find(hp => hp.id === id)
1166 if (!target) return false
1167 // A card in a folded group is not drawn: open the group first.
1168 const group = target.phase === 'working' ? 'running' : target.phase === 'done' ? 'finished' : 'stopped'
1169 const { value: view = { open: [], folded: [] } } = await $.state.get(VIEW)
1170 if (isFolded(view, group)) await toggleIn($, 'folded', group)
1171 const now = await $.clock.now()
1172 focusTimer?.cancel()
1173 focusTimer = $.clock.after(FOCUS_MS, () => {
1174 focusTimer = null
1175 void $.state.set(FOCUS, null).catch(() => undefined)
1176 })
1177 await $.state.set(FOCUS, { id, until: now + FOCUS_MS })
1178 await $.ui.scroll({ to: { key: `card-${id}` }, in: PANE, block: 'center' }).catch(err => log($, `scroll: ${String(err)}`))
1179 return true
1180}
1181
1182async function setMotion($: EngineInterface, value: boolean) {
1183 motion = value
1184 await $.store.set(MOTION_KEY, value)
1185 await $.state.set(MOTION, value)
1186 syncTimers($)
1187}
1188
1189const GROUPS = ['running', 'finished', 'stopped']
1190
1191/** The cards as the pane lists them, top to bottom: Running, then Finished, then Stopped. */
1192function byGroup(list: Helper[]) {
1193 const running = list.filter(hp => hp.phase === 'working')
1194 const finished = list.filter(hp => hp.phase === 'done')
1195 const stopped = list.filter(hp => hp.phase === 'failed')
1196 return { running, finished, stopped, ordered: [...running, ...finished, ...stopped] }
1197}
1198
1199async function hidePane($: EngineInterface) {
1200 await setHidden($, true)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 110 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 /** The Agent tool's `name` input, empty when none was given. */
22 name: string
23 /** The effort the Agent call asked for (low, medium, high, xhigh, max); medium when it named none. Sets the leg pace. */
24 level: string
25 /** Peers this helper sent a message to and heard from, newest last, at most 3 each. A peer is a helper id or "main". */
26 sentTo: string[]
27 heardFrom: string[]
28 /** Display model ("Opus 5.5"), empty when not known yet. */
29 model: string
30 /** Effort the engine asked for on its last step, empty when not known. */
31 effort: string
32 phase: HelperPhase
33 /** Clock time it started and, once over, when it ended. */
34 startedAt: number
35 endedAt: number
36 /** How many tool calls it made in all. */
37 count: number
38 /** Its last 200 steps, oldest first; the last one is the live step. */
39 steps: Step[]
40 /** Tokens from finished turns, plus the live turn's steps so far. */
41 tokens: number
42 turnTokens: number
43 /** True once the engine reported any usage for it. */
44 hasUsage: boolean
45 /** Position in the desk, in start order. */
46 seat: number
47 /** Which of the 8 body colours its pet wears; running helpers never share one while there are 8 or fewer. */
48 hue: number
49}
50
51export type JevPhase = 'asking' | 'answered' | 'skipped' | 'judging' | 'judged'
52
53/** One Jev call as jev-lab writes it to ~/.claude/logs/jev-live/<sessionId>.json. */
54export type JevCall = {
55 id: string
56 at: number
57 description: string
58 subagentType: string
59 chosen: string
60 chosenEffort: string
61 phase: JevPhase
62 skipReason?: 'restricted' | 'rubric-unreadable' | 'timeout' | 'error'
63 tier?: 'haiku' | 'sonnet' | 'opus'
64 effort?: string
65 probs?: { haiku: number; sonnet: number; opus: number }
66 judge?: { tier: string; effort: string; model: string; reason: string }
67 /** Set by the mod: the call has been open past the cap, so it no longer reads as live. */
68 stale?: boolean
69}
70
71export type PlanStatus = 'pending' | 'in_progress' | 'completed'
72
73/** What the plan band draws: the chat's plan, and whether the band shows it now. */
74export type BandPlan = {
75 title: string
76 steps: { text: string; status: PlanStatus }[]
77 /** False once a finished plan has held at 100% for 20 s, or for an empty list. */
78 shown: boolean
79}
80
81declare module 'claude-code' {
82 interface PluginState {
83 'helper-desk': {
84 helpers: Helper[]
85 /** Tick count (spinner and pet frames) and the clock time of the last tick, for elapsed times. */
86 clock: { tick: number; now: number }
87 /** Which cards show their full step list, and which groups are folded. */
88 view: { open: string[]; folded: string[] }
89 /** The chat's /cost total in US dollars, null where the host keeps none. */
90 cost: { usd: number | null }
91 /** Jev input tokens billed in this chat, from jev-lab's live file. */
92 jevTokens: number
93 /** True while switches.json turns the mod off. */
94 off: boolean
95 /** True while switches.json hides the band and the pane ("helper-desk:hidden"). */
96 hidden: boolean
97 /** The plan the band draws; null before the first plan. */
98 plan: BandPlan | null
99 /** Shimmer tick for the step in progress; it counts only while one is in progress. */
100 shimmer: number
101 /** The Jev calls to draw, newest last; empty without a readable file. */
102 jev: JevCall[]
103 /** The helper card the pane just scrolled to, highlighted until `until` (clock ms). */
104 focus: { id: string; until: number } | null
105 /** False when /helpers motion off stops the pets; kept in $.store across sessions. */
106 motion: boolean
107 }
108 }
109}
110