SLOPSHOPPER

helper-desk

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

newpanebandguardcommandtoast
v0.6.3no licenseupdated 2026-10-25jz-wilson/claude-mods/helper-desk
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 │ ┃ Focus this pane to press its buttons, or use ⏺ 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. Focus this pane to press its buttons, or use /helpers h…
README

claude-mods

Mods for Claude Code.

ModWhat it does
cache-barStatus-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.
filetreeFork 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-deskTask list above the prompt that follows Claude's to-do list, plus a side pane with one live card per subagent.
helpin-perPane listing open Helpin PER-* tasks; pick one to have Claude start working on it.
cache-clockCountdown above the prompt until the chat's cache goes cold, with a Keep warm button.
handoffWhen 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).
reflectWhen you correct Claude, offers to save the fix as a lasting rule in CLAUDE.md or a rules folder.
chat-inventorySide 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-optimizerBefore 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-refinerA 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 setting

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.

Install

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 and claude-hud

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:

  • backs up settings.json to settings.json.bak-cache-bar;
  • moves the current statusLine.command (claude-hud's, for example) into inner in ~/.claude/plugins/cache-bar/config.json;
  • points 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.

Development

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-bar
Source 3 files
hooks/register.tsx 2336 lines
1import 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 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 110 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  /** 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