Live crew panel inside Claude: every subagent with model, effort, steps, context, estimated cost and time, plus a job bar above the prompt and your 5-hour and…

See what every helper Claude is doing, and what it costs, live.
A Claude Code mod: it draws inside Claude itself (the desktop app, VS Code and the terminal). When Claude splits work across subagents, Luff Crew gives you a live panel of the whole crew: who's running, who's finished, what's still planned, and for each one the model, effort, step progress, context fill, estimated cost and time. A job bar sits above the prompt, and your 5-hour and weekly plan limits are always one glance away.
<img src="docs/panel-light.png" alt="The Luff Crew panel and job bar in light theme" width="48%"> <img src="docs/panel-dark.png" alt="The Luff Crew panel and job bar in dark theme" width="48%">
2/5 · running tests). A subagent that doesn't report shows its context fill in grey instead./agents-info opens or closes the panel. It also opens on its own when a job with a plan starts, or when the first subagent of a session starts./agents-web opens the same crew in your browser on localhost, for when you want it on a second screen. It never opens by itself.localhost only and only while you have it on.In a terminal, run:
/plugin install luff-crew-monitor --marketplace tanwar-harsh/luff-crew-monitor
Answer y to add the marketplace and pick the user scope. From then on it runs in every session: terminal, desktop app and VS Code. (The desktop app's Code tab can't run /plugin, but a mod installed from a terminal loads there too.)
Or, from your shell:
claude plugin marketplace add tanwar-harsh/luff-crew-monitor
claude plugin install luff-crew-monitor@luff-crew
Requires Claude Code 2.1.295 or newer. Run claude update; the desktop app updates itself.
Already installed? Pull the latest version, then restart Claude (or run /reload-plugins):
claude plugin update luff-crew-monitor@luff-crew
For other MCP clients, the same tracker also ships as a standalone MCP server with a browser dashboard on localhost:
npm install -g https://codeload.github.com/tanwar-harsh/luff-crew-monitor/tar.gz/refs/heads/main
luff-crew-monitor setup
Don't use both in Claude Code: the mod already provides the same tools.
/agents-info to open the panel.Inside Claude is the default and needs nothing. If you'd also like the crew in a browser tab:
| Command | What it does |
|---|---|
/agents-web | Starts the browser view for this session on localhost (port 7420, or the next free one) and opens it. Run it again to reopen the tab. |
/agents-web stop | Stops it. It also stops when the session ends. |
The panel's Open in browser button does the same. The browser view needs Node.js 18.17 or newer on your machine; it's the package's own dashboard (dist/luff-crew.mjs) following this session's transcript.
You don't need to prompt anything special. The mod adds a short note to Claude's system prompt so it reports progress and asks its subagents for steps. If you want the job bar to be precise, say "plan it as tasks for subagents" in your request.
The panel at the top tracks the two Claude.ai plan windows: the 5-hour session and the weekly limit.
◔ 5h 38%).Plan percentages exist only for Claude.ai subscriptions (Pro, Max, Team, Enterprise). With an API key, Bedrock or Vertex the meters stay empty and the local totals cover the last 5 hours and 7 days.
The mod gives Claude two tools. The note it adds to the system prompt tells Claude when to call them.
| Tool | Who calls it | What it does |
|---|---|---|
mcp__luff-crew-monitor__progress | Orchestrator | Reports the job: title, tasks[] (title, agent, model, effort, after), phase (plan, design, delegate, review, close), done, total, finished. Omitted fields keep their value. A new title starts a new job. |
mcp__luff-crew-monitor__step | Subagent | Reports its own steps: done, total, note. |
Planned tasks match runs by title, so each task's title should equal the Agent tool description it's delegated with.
The only option is the panel language: auto (the default) follows Claude Code's language setting, then your locale. en and ru are available. Set it with /plugin configure luff-crew-monitor@luff-crew, or in ~/.claude/settings.json:
{ "pluginConfigs": { "luff-crew-monitor@luff-crew": { "options": { "language": "en" } } } }
The mod is a hooks module (hooks/register.tsx) that Claude Code loads into every session:
agent.spawn, turn.step and turn.complete give it each subagent's start, model, effort, token usage and end. Cost comes from token counts and the price table in src/pricing.ts.session.measure gives the 5-hour and weekly plan windows.tool.call answers progress and step. A subagent's call carries its loop's id, which is how its steps land on the right row.turn.start, turn.step and turn.complete on the main loop drive the per-prompt bar.Pane and an AbovePrompt render hook draw the panel and the bar. Words are the app's own themed text. Mascots, bars and limit rings are small SVGs (desktop, VS Code) or text (terminal)./agents-web runs node dist/luff-crew.mjs dashboard --session <id> as a child of the session ($.process.spawn); the child ends with the session or on /agents-web stop.Inside Claude it needs no server, no browser and no network.
The package also ships a CLI that reads Claude Code's transcripts directly. It's for other MCP clients, or a browser view outside Claude.
luff-crew-monitor MCP server over stdio
luff-crew-monitor setup Register it with `claude mcp add` [--statusline] [--scope user|project|local] [--force] [--dry-run]
luff-crew-monitor dashboard Browser dashboard following the latest session (or one: --session <id>) [--project <dir>] [--session <id>] [--port <n>] [--no-open]
luff-crew-monitor status Print the crew once as text
luff-crew-monitor statusline Status line command [--wrap "<existing command>"] [--no-color]
luff-crew-monitor doctor Check the installation
luff-crew-monitor uninstall Undo setup
Its settings live in ~/.luff-crew/config.json, or in LUFF_CREW_PORT, LUFF_CREW_LANG, LUFF_CREW_AUTO_OPEN and LUFF_CREW_STALL_MINUTES. autoOpen is off by default: the MCP server opens the browser only when its open_dashboard tool is called. Set it to true to have the dashboard open by itself when a job with a plan starts.
npm install
npm run check
claude plugin test .
claude plugin validate .
npm run check runs the typecheck, the build and the Node tests (test/*.spec.ts).claude plugin test . runs the mod's own tests (hooks/*.test.tsx) against the engine, on the terminal and desktop surfaces.node scripts/mod-preview.ts light|dark renders the panel and job bar as HTML for the screenshots in docs/.claude --plugin-dir .. Edits reload when a turn ends.dist/ is committed so installs need no build step. Run npm run build before committing source changes.MIT © 2026 Harsh Tanwar
hooks/register.tsx 1098 lines1// Luff Crew as a Claude Code mod: a live crew panel and a job bar drawn inside
2// Claude itself (desktop app, VS Code, terminal), fed straight from engine events.
3
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import { applyProgress, cleanTasks, startsNewFlow } from '../src/flow.ts'
8import { clip, fmtCost, fmtDuration, fmtTime, fmtTokens, normalize } from '../src/format.ts'
9import { STRINGS } from '../src/i18n.ts'
10import { contextWindowOf, costOf, modelLabel, tokensOf } from '../src/pricing.ts'
11import type { ProgressInput } from '../src/types.ts'
12import type { CrewFlow, CrewLimitWindow, CrewPanel, CrewRun, CrewTask, CrewTurn, CrewUsageBuckets } from '../types'
13import {
14 ACCENT,
15 BAD,
16 barSvg,
17 ctxPercent,
18 MASCOT_H,
19 MASCOT_W,
20 mascotSvg,
21 OK,
22 progressOf,
23 runMeta,
24 runStats,
25 SEG_H,
26 segmentsSvg,
27 statusColor,
28 statusGlyph,
29 stepText,
30 STRIP_H,
31 stripSvg,
32 textBar,
33 meterColor,
34 limitPairs,
35 ringSvg,
36 RING,
37 typeColor,
38} from './draw.ts'
39
40const PLUGIN = 'luff-crew-monitor'
41const PANE = 'luff-crew'
42const TOOL_PROGRESS = `mcp__${PLUGIN}__progress`
43const TOOL_STEP = `mcp__${PLUGIN}__step`
44const HOUR = 3_600_000
45const FIVE_HOURS = 5 * HOUR
46const SEVEN_DAYS = 7 * 24 * HOUR
47
48const flowAtom = atom({ plugin: 'luff-crew-monitor', key: 'flow' } as const, null)
49const runsAtom = atom({ plugin: 'luff-crew-monitor', key: 'runs' } as const, [])
50const turnAtom = atom({ plugin: 'luff-crew-monitor', key: 'turn' } as const, null)
51const webAtom = atom({ plugin: 'luff-crew-monitor', key: 'web' } as const, null)
52const panelAtom = atom({ plugin: 'luff-crew-monitor', key: 'panel' } as const, {
53 isCompact: false,
54 isFinishedOpen: true,
55 isLimitsOpen: true,
56 hiddenAt: 0,
57 autoOpenedFor: [],
58})
59const nowAtom = atom({ plugin: 'luff-crew-monitor', key: 'now' } as const, 0)
60const limitsAtom = atom({ plugin: 'luff-crew-monitor', key: 'limits' } as const, { windows: [], measuredAt: 0 })
61const usageAtom = atom({ plugin: 'luff-crew-monitor', key: 'usage' } as const, {})
62const othersAtom = atom({ plugin: 'luff-crew-monitor', key: 'others' } as const, {})
63
64type Lang = 'en' | 'ru'
65let lang: Lang = 'en'
66const s = () => STRINGS[lang]
67
68// This session's own key in `$.store`, so concurrent sessions never overwrite each other.
69let storeKey = ''
70
71const PROTOCOL = `## Luff Crew (subagent progress)
72The user watches subagents on the Luff Crew panel. When you split a job across subagents (the Agent tool):
731. After presenting the plan, call \`${TOOL_PROGRESS}\` with a short title, the tasks (each title EXACTLY equal to the Agent \`description\` you will use) and phase "delegate".
742. Each time you accept a finished task, call it with the new \`done\` count; switch \`phase\` to "review" while checking results.
753. When everything is finished, call it once with \`finished: true\`.
76In each subagent's prompt, ask it to call \`${TOOL_STEP}\` right after reading its brief (done: 0, total: its plan in 3-8 steps) and again as each step finishes, loading it first with ToolSearch (\`select:${TOOL_STEP}\`) if it is deferred.
77Skip all of this for work you do yourself without subagents.`
78
79// ---------------------------------------------------------------------------
80// Helpers
81
82const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
83
84async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
85 if (option === 'en' || option === 'ru') return option
86 try {
87 const settings = (await $.settings.read()) as { language?: unknown }
88 if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
89 } catch {
90 // No settings: fall through to the locale.
91 }
92 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LANG'))
93 return isRussian(locale) ? 'ru' : 'en'
94}
95
96const failedWords = () => ({ error: s().error, killed: s().killed, stalled: s().stalled })
97
98/** Runs on screen: a new job hides runs that ended before it began. */
99const visible = (flow: CrewFlow | null, runs: CrewRun[]): CrewRun[] =>
100 flow ? runs.filter(r => r.status === 'running' || (r.endedAt ?? r.startedAt) >= flow.startedAt) : runs
101
102/** Planned tasks no run has picked up yet. */
103function pending(flow: CrewFlow | null, runs: CrewRun[]): Array<CrewTask & { n: number }> {
104 if (!flow || flow.finished) return []
105 const started = new Set(runs.map(r => normalize(r.description)))
106 return flow.tasks.map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(normalize(t.title)))
107}
108
109function totals(runs: CrewRun[], now: number) {
110 let cost = 0
111 let tokens = 0
112 let start = Infinity
113 let end = 0
114 for (const r of runs) {
115 cost += r.costUsd
116 tokens += r.tokens
117 start = Math.min(start, r.startedAt)
118 end = Math.max(end, r.endedAt ?? Math.max(now, r.startedAt))
119 }
120 return { cost, tokens, time: runs.length ? end - start : 0 }
121}
122
123function flowLabel(f: CrewFlow, running: number): string {
124 const phase = f.phase === 'plan' && running > 0 ? 'delegate' : f.phase
125 if (f.finished) return s().phase.close
126 if (phase === 'plan' || phase === 'design') return s().phase[phase]
127 const count = f.total ? `${f.done}/${f.total}` : `${running} ${s().busy}`
128 return `${phase === 'review' ? s().phase.review : s().phase.delegate} ${count}`
129}
130
131const flowRatio = (f: CrewFlow): number => (f.finished ? 1 : f.total ? f.done / f.total : 0)
132
133function segments(f: CrewFlow, runs: CrewRun[]): Array<'done' | 'running' | 'todo'> {
134 const total = f.finished ? Math.max(f.total, 1) : f.total
135 const byTitle = new Map(runs.map(r => [normalize(r.description), r.status]))
136 const inFlight = f.tasks.length ? f.tasks.filter(t => byTitle.get(normalize(t.title)) === 'running').length : runs.filter(r => r.status === 'running').length
137 return Array.from({ length: total }, (_, i) => (f.finished || i < f.done ? 'done' : i < f.done + inFlight ? 'running' : 'todo'))
138}
139
140function sumBuckets(sources: CrewUsageBuckets[], since: number) {
141 const out = { tokens: 0, costUsd: 0, requests: 0 }
142 const first = Math.floor(since / HOUR)
143 for (const b of sources)
144 for (const [hour, v] of Object.entries(b)) {
145 if (Number(hour) < first) continue
146 out.tokens += v.tokens
147 out.costUsd += v.costUsd
148 out.requests += v.requests
149 }
150 return out
151}
152
153/** Other sessions' buckets from `$.store`, dropping entries older than a week. */
154async function loadOthers($: EngineInterface): Promise<void> {
155 const cutoff = Math.floor(((await $.clock.now()) - SEVEN_DAYS - HOUR) / HOUR)
156 const merged: CrewUsageBuckets = {}
157 for (const key of await $.store.keys()) {
158 if (!key.startsWith('usage:') || key === storeKey) continue
159 const value = (await $.store.get(key)) as CrewUsageBuckets | undefined
160 let fresh = 0
161 for (const [hour, v] of Object.entries(value ?? {})) {
162 if (Number(hour) < cutoff) continue
163 fresh++
164 const m = (merged[hour] ??= { tokens: 0, costUsd: 0, requests: 0 })
165 m.tokens += v.tokens
166 m.costUsd += v.costUsd
167 m.requests += v.requests
168 }
169 if (!fresh) await $.store.delete(key)
170 }
171 await update($, othersAtom, () => merged)
172}
173
174function toWindows(rateLimits: ReadonlyArray<{ kind: string; percentUsed: number; resetsAt?: string }>): CrewLimitWindow[] {
175 return rateLimits.map(w => {
176 const resetsAt = w.resetsAt ? Date.parse(w.resetsAt) : NaN
177 return { kind: w.kind, percentUsed: w.percentUsed, resetsAt: Number.isFinite(resetsAt) ? resetsAt : 0 }
178 })
179}
180
181async function togglePane($: EngineInterface): Promise<boolean> {
182 if ((await $.ui.panes()).some(p => p.id === PANE)) {
183 await $.ui.close({ id: PANE })
184 return false
185 }
186 await update($, nowAtom, () => Date.now())
187 await $.ui.open({ id: PANE, title: s().appName })
188 return true
189}
190
191/** Opens the panel by itself once per job (and once for the session's first subagent). */
192async function autoOpen($: EngineInterface, key: string): Promise<void> {
193 const panel = await read($, panelAtom)
194 if (panel.autoOpenedFor.includes(key)) return
195 await update($, panelAtom, p => ({ ...p, autoOpenedFor: [...p.autoOpenedFor, key].slice(-20) }))
196 if (!(await $.ui.panes()).some(p => p.id === PANE)) void $.ui.open({ id: PANE, title: s().appName })
197}
198
199// ---------------------------------------------------------------------------
200// The main loop's turn: every prompt moves the bar, subagents or not.
201
202/** `mcp__server__ctx_execute` → `ctx_execute`; built-in names stay. */
203const shortTool = (name: string): string => name.split('__').pop() || name
204
205const newTurn = (turnId: string, prompt: string, at: number): CrewTurn => ({
206 turnId,
207 prompt: clip(prompt.replace(/\s+/g, ' ').trim(), 120),
208 status: 'running',
209 startedAt: at,
210 steps: 0,
211 tools: 0,
212 tokens: 0,
213 costUsd: 0,
214})
215
216function turnWord(t: CrewTurn): string {
217 return t.status === 'running' ? s().working : t.status === 'done' ? s().answered : s().stopped
218}
219
220function turnStats(t: CrewTurn, now: number): string {
221 const time = fmtTime((t.endedAt ?? Math.max(now, t.startedAt)) - t.startedAt)
222 const tool = t.status === 'running' && t.lastTool ? ` (${t.lastTool})` : ''
223 return `${t.steps} ${s().steps} · ${t.tools} ${s().tools}${tool} · ${time} · ≈${fmtCost(t.costUsd)}`
224}
225
226/** One segment per model request (the last 24), the one in flight striped. */
227function turnSegments(t: CrewTurn): Array<'done' | 'running'> {
228 if (t.status !== 'running') return Array.from({ length: Math.max(1, Math.min(24, t.steps)) }, () => 'done' as const)
229 return [...Array.from({ length: Math.min(23, t.steps) }, () => 'done' as const), 'running' as const]
230}
231
232/** A block sliding along the track while a turn runs; full once it ends. */
233function pulseBar(t: CrewTurn, now: number, width: number): string {
234 if (t.status !== 'running') return textBar(1, width)
235 const block = Math.min(3, width)
236 const pos = Math.floor((now - t.startedAt) / 1000) % (width - block + 1)
237 return '▱'.repeat(pos) + '▰'.repeat(block) + '▱'.repeat(width - block - pos)
238}
239
240// ---------------------------------------------------------------------------
241// The browser view: optional, started only by /agents-web or the panel's button.
242// It is the package's own CLI dashboard (dist/luff-crew.mjs), following this
243// session's transcript on localhost; it ends with the session or on request.
244
245let stopWeb: (() => void) | null = null
246
247async function findNode($: EngineInterface): Promise<string | null> {
248 const works = async (bin: string): Promise<boolean> => {
249 try {
250 return (await $.process.run([bin, '--version'], { timeoutMs: 5000 })).exitCode === 0
251 } catch {
252 return false
253 }
254 }
255 if (await works('node')) return 'node'
256 const nvm = await $.env.get('NVM_BIN')
257 if (nvm && (await works(`${nvm}/node`))) return `${nvm}/node`
258 // Apps started from the Dock lack the shell's PATH: ask the login shell.
259 try {
260 const shell = (await $.env.get('SHELL')) || '/bin/sh'
261 const { stdout } = await $.process.run([shell, '-ilc', 'command -v node'], { timeoutMs: 8000 })
262 const found = stdout
263 .split('\n')
264 .map(l => l.trim())
265 .reverse()
266 .find(l => l.startsWith('/') && l.endsWith('/node'))
267 if (found && (await works(found))) return found
268 } catch {
269 // No login shell: try the usual places.
270 }
271 for (const bin of ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node']) if (await works(bin)) return bin
272 return null
273}
274
275async function openUrl($: EngineInterface, url: string): Promise<void> {
276 for (const argv of [['open', url], ['xdg-open', url], ['cmd', '/c', 'start', '""', url]]) {
277 try {
278 if ((await $.process.run(argv, { timeoutMs: 5000 })).exitCode === 0) return
279 } catch {
280 // Not this platform's opener.
281 }
282 }
283}
284
285async function startWebView($: EngineInterface): Promise<string> {
286 const cur = await read($, webAtom)
287 if (cur?.status === 'running' && stopWeb) {
288 await openUrl($, cur.url)
289 return `${s().webAt}: ${cur.url}`
290 }
291 if (cur?.status === 'starting') return s().webStarting
292 await update($, webAtom, () => ({ status: 'starting' as const, url: '', startedAt: Date.now() }))
293
294 const node = await findNode($)
295 if (!node) {
296 await update($, webAtom, () => null)
297 return 'The browser view needs Node.js 18.17 or newer, and none was found on PATH. Install Node.js, then run /agents-web again.'
298 }
299 const [cwd, sessionId] = await Promise.all([$.session.cwd(), $.session.id()])
300 const argv = [node, `${$.plugin.root}/dist/luff-crew.mjs`, 'dashboard', '--project', cwd, ...(sessionId ? ['--session', sessionId] : [])]
301 const child = $.process.spawn({ argv, env: { LUFF_CREW_LANG: lang } })
302
303 let settle: (url: string | null) => void = () => {}
304 const found = new Promise<string | null>(resolve => (settle = resolve))
305 let isStopped = false
306 stopWeb = () => {
307 isStopped = true
308 void child.return(undefined as never).catch(() => {})
309 }
310 void (async () => {
311 let out = ''
312 let errors = ''
313 try {
314 for await (const { stream, text } of child) {
315 if (stream === 'stderr') errors = (errors + text).slice(-2000)
316 out = (out + text).slice(-4000)
317 const m = /dashboard:\s*(https?:\/\/\S+)/i.exec(out)
318 if (m) {
319 settle(m[1]!)
320 await update($, webAtom, () => ({ status: 'running' as const, url: m[1]!, startedAt: Date.now() }))
321 out = ''
322 }
323 }
324 } catch (err) {
325 errors ||= String(err)
326 }
327 settle(null)
328 stopWeb = null
329 await update($, webAtom, () => null).catch(() => {})
330 if (!isStopped && errors.trim()) $.ui.log(`luff-crew: the browser view stopped: ${clip(errors.trim(), 300)}`, { to: 'debug' })
331 })()
332
333 const url = await Promise.race([found, $.clock.sleep(15_000).then(() => null)])
334 if (url) return `${s().webAt}: ${url} (opened in your browser; /agents-web stop ends it)`
335 stopWeb?.()
336 return 'The browser view did not start. Run Claude Code with --debug for the reason, or start it by hand: luff-crew-monitor dashboard'
337}
338
339async function stopWebView($: EngineInterface): Promise<string> {
340 const wasRunning = !!stopWeb
341 stopWeb?.()
342 stopWeb = null
343 await update($, webAtom, () => null)
344 return wasRunning ? 'Browser view stopped.' : 'No browser view is running.'
345}
346
347// ---------------------------------------------------------------------------
348
349export const register: Register = (on, options) => {
350 on('session.start', async ($, e, next) => {
351 const started = await next(e)
352 lang = await detectLang($, options.language)
353 const at = await $.clock.now()
354 storeKey = `usage:${at.toString(36)}${Math.random().toString(36).slice(2, 6)}`
355
356 await $.tool.register({
357 name: 'progress',
358 isDeferred: false,
359 description:
360 'Report the progress of a multi-agent job to the Luff Crew panel and job bar. ' +
361 'Call it after presenting the plan (title, tasks, phase "delegate"), each time a task is accepted (done), ' +
362 'when switching phase or re-planning (tasks), and once at the end with finished: true. ' +
363 'Omitted fields keep their previous value; a new title starts a new job.',
364 inputSchema: {
365 type: 'object',
366 properties: {
367 title: { type: 'string', description: 'Short name of the overall job, a few words.' },
368 total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks; defaults to the number of tasks.' },
369 done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted so far.' },
370 phase: { type: 'string', enum: ['plan', 'design', 'delegate', 'review', 'close'] },
371 finished: { type: 'boolean', description: 'True once the whole job is closed.' },
372 tasks: {
373 type: 'array',
374 description: 'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` it will be delegated with.',
375 items: {
376 type: 'object',
377 properties: {
378 title: { type: 'string' },
379 agent: { type: 'string', description: 'Subagent type, e.g. general-purpose or Explore.' },
380 model: { type: 'string', description: 'Expected model, e.g. opus, sonnet, haiku.' },
381 effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max'] },
382 after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
383 },
384 required: ['title'],
385 },
386 },
387 },
388 },
389 })
390 await $.tool.register({
391 name: 'step',
392 isDeferred: false,
393 description:
394 'For subagents: report progress on your own task to the Luff Crew panel. Right after reading your brief, call it with ' +
395 '`total` (your plan in 3-8 steps) and `done: 0`; call it again as each step finishes. Cheap and silent: it only moves your bar.',
396 inputSchema: {
397 type: 'object',
398 properties: {
399 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
400 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change.' },
401 note: { type: 'string', description: 'The step in progress, a few words.' },
402 },
403 required: ['done'],
404 },
405 })
406 await $.command.register({
407 name: 'agents-info',
408 description: 'Show or hide the Luff Crew panel: subagents, plan limits, cost and time',
409 immediate: true,
410 })
411 await $.command.register({
412 name: 'agents-web',
413 description: 'Open the Luff Crew view in your browser (localhost); /agents-web stop ends it',
414 argumentHint: '[stop]',
415 immediate: true,
416 })
417
418 try {
419 const usage = await $.session.usage()
420 if (usage.rateLimits.length) await update($, limitsAtom, () => ({ windows: toWindows(usage.rateLimits), measuredAt: at }))
421 } catch {
422 // No reading yet: the first response brings one.
423 }
424 await loadOthers($).catch(() => {})
425
426 // Running clocks tick once a second; other sessions' usage refreshes each minute.
427 let ticks = 0
428 $.clock.every(1000, () => {
429 void (async () => {
430 ticks++
431 if (ticks % 60 === 0) await loadOthers($).catch(() => {})
432 const runs = await read($, runsAtom)
433 const turn = await read($, turnAtom)
434 if (runs.some(r => r.status === 'running') || turn?.status === 'running' || ticks % 30 === 0) await update($, nowAtom, () => Date.now())
435 })()
436 })
437 return started
438 })
439
440 // Teach the model the protocol, for the whole session.
441 on('prompt.compose', async ($, e, next) => {
442 const composed = await next(e)
443 return { sections: [...composed.sections, { id: 'luff-crew-protocol', text: PROTOCOL, scope: 'session' as const }] }
444 })
445
446 on('command.run', { command: 'agents-info' }, async $ => {
447 const isOpen = await togglePane($)
448 return { text: isOpen ? 'Luff Crew panel opened.' : 'Luff Crew panel closed.' }
449 })
450
451 on('command.run', { command: 'agents-web' }, async ($, e) => {
452 const arg = e.args.trim().toLowerCase()
453 return { text: arg === 'stop' || arg === 'off' || arg === 'close' ? await stopWebView($) : await startWebView($) }
454 })
455
456 // Each prompt starts a turn of the main loop: the bar follows it from here.
457 on('turn.start', async ($, e, next) => {
458 try {
459 if (!(e as { agentId?: string }).agentId) {
460 const at = await $.clock.now()
461 await update($, turnAtom, () => newTurn(e.turnId, e.text, at))
462 await update($, nowAtom, () => at)
463 }
464 } catch {
465 // Bookkeeping never holds up a turn.
466 }
467 return next(e)
468 })
469
470 on('tool.call', { tool: TOOL_PROGRESS }, async ($, e) => {
471 const input = e as unknown as ProgressInput
472 const at = await $.clock.now()
473 const prev = await read($, flowAtom)
474 if (startsNewFlow(prev, input) && input.title?.trim()) {
475 // A new job starts with a clean list; agents still running stay.
476 await update($, runsAtom, list => list.filter(r => r.status === 'running'))
477 }
478 const tasks = cleanTasks(input.tasks)
479 const next = await update($, flowAtom, p => applyProgress(p, { ...input, ...(tasks ? { tasks } : {}) }, at) as CrewFlow)
480 await update($, panelAtom, p => ({ ...p, hiddenAt: 0 }))
481 const running = (await read($, runsAtom)).filter(r => r.status === 'running').length
482 if (next && input.tasks?.length) await autoOpen($, `job:${next.title}`)
483 return { result: `ok: ${next ? flowLabel(next, running) : 'ok'}` }
484 }).catch(() => ({ result: 'ok' }))
485
486 // A worker's own steps: the call runs in the worker's loop, so `agentId` names it.
487 on('tool.call', { tool: TOOL_STEP }, async ($, e) => {
488 const input = e as unknown as { done?: number; total?: number; note?: string }
489 const agentId = e.agentId
490 if (!agentId) return { result: 'ignored: only subagents report steps' }
491 const int = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : undefined)
492 await update($, runsAtom, list =>
493 list.map(r => {
494 if (r.agentId !== agentId) return r
495 const total = int(input.total) ?? r.step?.total ?? 0
496 const done = int(input.done) ?? r.step?.done ?? 0
497 const note = typeof input.note === 'string' ? clip(input.note.trim(), 80) : ''
498 return { ...r, step: { total, done: total ? Math.min(total, done) : done, ...(note ? { note } : {}) } }
499 }),
500 )
501 return { result: 'ok' }
502 }).catch(() => ({ result: 'ok' }))
503
504 on('agent.spawn', async ($, e, next) => {
505 const started = await next(e)
506 if (started.deny !== undefined) return started
507 const at = await $.clock.now()
508 await update($, runsAtom, list => {
509 const round = 1 + list.filter(r => e.description && normalize(r.description) === normalize(e.description)).length
510 const run: CrewRun = {
511 id: started.agentId ?? e.tool_use_id,
512 type: e.subagentType,
513 description: e.description,
514 model: started.model,
515 status: 'running',
516 startedAt: at,
517 contextTokens: 0,
518 contextMax: contextWindowOf(started.model),
519 tokens: 0,
520 costUsd: 0,
521 requests: 0,
522 round,
523 }
524 if (started.agentId) run.agentId = started.agentId
525 return [...list.filter(r => r.id !== run.id), run].slice(-200)
526 })
527 await update($, nowAtom, () => at)
528 await autoOpen($, 'first-subagent')
529 return started
530 }).catch(($, e, next) => next(e)) // never block a launch over bookkeeping
531
532 // Every model request, main loop and subagents: usage for the limits panel,
533 // and a subagent's live model, effort, context, tokens and cost.
534 on('turn.step', async function* ($, e, next) {
535 const result = yield* next(e)
536 const at = await $.clock.now()
537 const usage = result.usage
538 const model = usage?.model || e.model
539 const tokens = usage ? tokensOf(usage) : 0
540 const cost = usage ? costOf(model, usage) : 0
541
542 // The main loop's turn: one more request, the tools it asked for.
543 if (!e.agentId) {
544 const last = result.toolUses.at(-1)?.name
545 await update($, turnAtom, t => {
546 const base = t && t.turnId === e.turnId ? t : newTurn(e.turnId, '', at)
547 return {
548 ...base,
549 status: 'running' as const,
550 steps: base.steps + 1,
551 tools: base.tools + result.toolUses.length,
552 ...(last ? { lastTool: shortTool(last) } : {}),
553 tokens: base.tokens + tokens,
554 costUsd: base.costUsd + cost,
555 }
556 })
557 }
558 if (!usage) return result
559
560 const hour = String(Math.floor(at / HOUR))
561 const own = await update($, usageAtom, b => {
562 const cur = b[hour] ?? { tokens: 0, costUsd: 0, requests: 0 }
563 const kept = Object.fromEntries(Object.entries(b).filter(([h]) => Number(h) >= Math.floor((at - SEVEN_DAYS) / HOUR)))
564 return { ...kept, [hour]: { tokens: cur.tokens + tokens, costUsd: cur.costUsd + cost, requests: cur.requests + 1 } }
565 })
566 if (storeKey) void $.store.set(storeKey, own).catch(() => {})
567
568 const agentId = e.agentId
569 if (agentId) {
570 await update($, runsAtom, list =>
571 list.map(r =>
572 r.agentId !== agentId
573 ? r
574 : {
575 ...r,
576 model,
577 ...(typeof e.effort === 'string' ? { effort: e.effort } : {}),
578 status: 'running' as const,
579 contextTokens: tokens,
580 contextMax: contextWindowOf(model),
581 tokens: r.tokens + tokens,
582 costUsd: r.costUsd + cost,
583 requests: r.requests + 1,
584 },
585 ),
586 )
587 }
588 return result
589 })
590
591 on('turn.complete', async ($, e, next) => {
592 const agentId = e.agentId
593 if (agentId) {
594 const at = await $.clock.now()
595 const reason = e.reason
596 await update($, runsAtom, list =>
597 list.map(r => {
598 if (r.agentId !== agentId) return r
599 const out: CrewRun = { ...r, status: reason === 'answer' ? 'done' : 'failed', endedAt: at }
600 if (reason === 'aborted') out.failure = 'killed'
601 else if (reason !== 'answer') out.failure = 'error'
602 else delete out.failure
603 // A run whose steps went unseen still gets the turn's own sum.
604 if (r.requests === 0 && e.usage) {
605 const model = e.usage.model || r.model
606 out.model = model
607 out.tokens = tokensOf(e.usage)
608 out.costUsd = costOf(model, e.usage)
609 }
610 return out
611 }),
612 )
613 await update($, nowAtom, () => at)
614 } else {
615 const at = await $.clock.now()
616 const reason = e.reason
617 await update($, turnAtom, t => {
618 if (!t || (t.turnId !== e.turnId && t.status !== 'running')) return t
619 const out: CrewTurn = { ...t, status: reason === 'answer' ? 'done' : 'failed', endedAt: at }
620 if (reason === 'aborted') out.failure = 'stopped'
621 else if (reason !== 'answer') out.failure = 'error'
622 else delete out.failure
623 delete out.lastTool
624 return out
625 })
626 await update($, nowAtom, () => at)
627 }
628 return next(e)
629 })
630
631 // Plan usage: the 5-hour and weekly windows, as the status line has them.
632 on('session.measure', async ($, e, next) => {
633 if (e.changed.includes('rateLimits')) {
634 const at = await $.clock.now()
635 await update($, limitsAtom, () => ({ windows: toWindows(e.rateLimits), measuredAt: at }))
636 }
637 return next(e)
638 })
639
640 // ---------------------------------------------------------------------------
641 // The panel
642
643 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
644 const ui = $.ui.resolve(e)
645 const { Box, Text, Button } = ui
646 const flow = await read($, flowAtom)
647 const all = await read($, runsAtom)
648 const panel: CrewPanel = await read($, panelAtom)
649 const limits = await read($, limitsAtom)
650 const own = await read($, usageAtom)
651 const others = await read($, othersAtom)
652 const turn = await read($, turnAtom)
653 const web = await read($, webAtom)
654 const now = Math.max(await read($, nowAtom), ...all.map(r => r.startedAt), turn?.startedAt ?? 0, 0)
655
656 const runs = visible(flow, all)
657 const running = runs.filter(r => r.status === 'running').reverse()
658 const finished = runs.filter(r => r.status !== 'running').reverse()
659 const planned = pending(flow, runs)
660 const t = totals(runs, now)
661 const title = flow && !flow.finished ? flow.title : ''
662 const isEmpty = !runs.length && !planned.length
663 const words = { round: s().round, failed: failedWords() }
664
665 const five = limits.windows.find(w => w.kind === 'five_hour')
666 const week = limits.windows.find(w => w.kind === 'seven_day')
667 const limitRows = [
668 { label: s().fiveHour, window: five, span: s().lastHours, since: five?.resetsAt ? five.resetsAt - FIVE_HOURS : now - FIVE_HOURS },
669 { label: s().weekly, window: week, span: s().lastDays, since: week?.resetsAt ? week.resetsAt - SEVEN_DAYS : now - SEVEN_DAYS },
670 ].map(r => ({ ...r, local: sumBuckets([own, others], r.since) }))
671
672 const toggleCompact = (
673 <Button key="compact" plain label={panel.isCompact ? s().expand : s().collapse} onPress={() => update($, panelAtom, p => ({ ...p, isCompact: !p.isCompact }))} />
674 )
675 const toggleLimits = (
676 <Button
677 key="limits"
678 plain
679 label={`${panel.isLimitsOpen ? '▾' : '▸'} ${s().limits}`}
680 onPress={() => update($, panelAtom, p => ({ ...p, isLimitsOpen: !p.isLimitsOpen }))}
681 />
682 )
683 const turnHead = turn ? `${turn.status === 'running' ? s().thisPrompt : s().lastPrompt}: ${turnWord(turn)}` : ''
684 const turnLine = turn ? `${turn.prompt ? `“${clip(turn.prompt, 60)}” · ` : ''}${turnStats(turn, now)}` : ''
685 const webButton = (
686 <Button
687 key="web"
688 plain
689 label={web ? (web.status === 'running' ? s().stopWeb : s().webStarting) : s().openWeb}
690 onPress={async () => {
691 const cur = await read($, webAtom)
692 if (cur?.status === 'running') await stopWebView($)
693 else if (!cur) $.ui.toast(await startWebView($))
694 }}
695 />
696 )
697 const webLine = web?.status === 'running' ? `${s().webAt}: ${web.url}` : ''
698 const toggleFinished = (
699 <Button
700 key="finished"
701 plain
702 label={`${panel.isFinishedOpen ? '▾' : '▸'} ${s().finished} · ${finished.length}`}
703 onPress={() => update($, panelAtom, p => ({ ...p, isFinishedOpen: !p.isFinishedOpen }))}
704 />
705 )
706
707 if (e.surface !== 'terminal' && 'Svg' in ui) {
708 const { Svg } = ui
709 const W = Math.max(260, Math.min(900, (e.props.bodyColumns || 48) * 8 - 8))
710 const barW = Math.max(120, W - MASCOT_W - 34)
711 const icons = [
712 ...running.map(r => ({ type: r.type, effort: r.effort, running: true, dim: false })),
713 ...finished.map(r => ({ type: r.type, effort: r.effort, running: false, dim: false })),
714 ...planned.map(p => ({ type: p.agent ?? 'planned', effort: p.effort, running: false, dim: true })),
715 ]
716 // Words are engine Text (themed by the app); pictures are theme-neutral SVG.
717 const runRow = (r: CrewRun, prefix: string) => {
718 const color = typeColor(r.type)
719 const progress = progressOf(r)
720 const steps = stepText(r)
721 return (
722 <Box key={`${prefix}-${r.id}`} flexDirection="row" gap={1} alignItems="center">
723 <Svg key="m" source={mascotSvg(r.type, r.effort, { running: r.status === 'running', dim: r.status === 'failed' })} alt={r.type} width={MASCOT_W} height={MASCOT_H} />
724 <Box key="body" flexDirection="column" flexGrow={1}>
725 <Text key="d" bold wrap="truncate-end">
726 <Text color={statusColor(r.status, color)}>{statusGlyph(r.status)}</Text> {r.description || r.type}
727 </Text>
728 <Text key="meta" wrap="truncate-end">
729 <Text color={color} bold>
730 {r.type}
731 </Text>
732 <Text dimColor> · {runMeta(r, words)}</Text>
733 </Text>
734 <Text key="stats" dimColor wrap="truncate-end">
735 {steps ? `${steps} ` : ''}
736 {runStats(r, now)}
737 </Text>
738 <Svg
739 key="bar"
740 source={barSvg(barW, progress ?? ctxPercent(r) / 100, progress === null ? '#8a8a8a' : r.status === 'failed' ? BAD : color, 4)}
741 alt={progress === null ? `context ${ctxPercent(r)}%` : `${Math.round(progress * 100)}%`}
742 width={barW}
743 height={4}
744 />
745 </Box>
746 </Box>
747 )
748 }
749 const limitRow = (row: (typeof limitRows)[number]) => {
750 const pct = row.window?.percentUsed
751 const reset = row.window?.resetsAt ? ` · ${s().resetsIn} ${fmtDuration(row.window.resetsAt - now)}` : ''
752 return (
753 <Box key={`limit-${row.label}`} flexDirection="column">
754 <Box key="head" flexDirection="row" justifyContent="space-between">
755 <Text key="l" bold>
756 {row.label}
757 </Text>
758 <Text key="r" dimColor>
759 {pct === undefined ? s().noPlan : `${Math.round(pct)}%${reset}`}
760 </Text>
761 </Box>
762 <Svg key="bar" source={barSvg(W, pct === undefined ? null : pct / 100, meterColor(pct ?? 0), 7)} alt={`${row.label} ${pct ?? 0}%`} width={W} height={7} />
763 <Text key="local" dimColor wrap="truncate-end">
764 {`${row.span}: ${fmtTokens(row.local.tokens)} ${s().tokensWord} · ≈${fmtCost(row.local.costUsd)} · ${row.local.requests} ${s().requests}`}
765 </Text>
766 </Box>
767 )
768 }
769 return (
770 <Box flexDirection="column" gap={1}>
771 {title ? (
772 <Text key="title" bold>
773 {title}
774 </Text>
775 ) : null}
776 <Text key="totals">
777 <Text dimColor>{s().cost} </Text>
778 <Text bold>≈{fmtCost(t.cost)}</Text>
779 <Text dimColor>{` ${s().tokens} `}</Text>
780 <Text bold>{fmtTokens(t.tokens)}</Text>
781 <Text dimColor>{` ${s().time} `}</Text>
782 <Text bold>{fmtTime(t.time)}</Text>
783 </Text>
784 {turn ? (
785 <Box key="turn" flexDirection="column">
786 <Text key="h" bold>
787 <Text color={statusColor(turn.status, ACCENT)}>{statusGlyph(turn.status)}</Text> {turnHead}
788 </Text>
789 <Text key="d" dimColor wrap="truncate-end">
790 {turnLine}
791 </Text>
792 </Box>
793 ) : null}
794 {toggleLimits}
795 {panel.isLimitsOpen ? limitRows.map(limitRow) : null}
796 {panel.isLimitsOpen && !limits.windows.length ? (
797 <Text key="limits-hint" dimColor>
798 {s().limitsHint}
799 </Text>
800 ) : null}
801 {toggleCompact}
802 {isEmpty ? (
803 <Text key="empty" dimColor>
804 {s().empty}
805 </Text>
806 ) : null}
807 {panel.isCompact && !isEmpty ? <Svg key="strip" source={stripSvg(W, icons)} alt={`${icons.length} ${s().agents}`} width={Math.min(W, icons.length * 38)} height={STRIP_H} /> : null}
808 {!panel.isCompact && running.length > 0 ? (
809 <Text key="h-running" dimColor>
810 {`${s().running} · ${running.length}`}
811 </Text>
812 ) : null}
813 {!panel.isCompact ? running.map(r => runRow(r, 'run')) : null}
814 {!panel.isCompact && finished.length > 0 ? toggleFinished : null}
815 {!panel.isCompact && panel.isFinishedOpen ? finished.map(r => runRow(r, 'fin')) : null}
816 {!panel.isCompact && planned.length > 0 ? (
817 <Text key="h-planned" dimColor>
818 {`${s().planned} · ${planned.length}`}
819 </Text>
820 ) : null}
821 {!panel.isCompact
822 ? planned.map(p => (
823 <Box key={`plan-${p.n}`} flexDirection="row" gap={1} alignItems="center">
824 <Svg key="m" source={mascotSvg(p.agent ?? 'planned', p.effort, { dim: true })} alt="planned" width={MASCOT_W} height={MASCOT_H} />
825 <Box key="body" flexDirection="column" flexGrow={1}>
826 <Text key="t" dimColor bold wrap="truncate-end">
827 ◷ {p.n}. {p.title}
828 </Text>
829 <Text key="m" dimColor wrap="truncate-end">
830 {[p.agent, p.model ? modelLabel(p.model) : '', p.effort, p.after.length ? `${s().after} ${p.after.join(', ')}` : ''].filter(Boolean).join(' · ') || ' '}
831 </Text>
832 </Box>
833 </Box>
834 ))
835 : null}
836 <Text key="estimate" dimColor>
837 {s().estimate}
838 </Text>
839 {webButton}
840 {webLine ? (
841 <Text key="web-url" dimColor wrap="truncate-end">
842 {webLine}
843 </Text>
844 ) : null}
845 </Box>
846 )
847 }
848
849 // Terminal: the same panel in text.
850 const cols = Math.max(30, e.props.bodyColumns || 60)
851 const barW = Math.max(8, Math.min(24, cols - 40))
852 const row = (r: CrewRun) => {
853 const progress = progressOf(r)
854 const color = typeColor(r.type)
855 const steps = stepText(r)
856 return (
857 <Box key={`row-${r.id}`} flexDirection="column" marginBottom={1}>
858 <Text wrap="truncate-end">
859 <Text color={r.status === 'failed' ? 'red' : r.status === 'done' ? 'green' : color}>{r.status === 'running' ? '●' : r.status === 'done' ? '✓' : '✗'}</Text>{' '}
860 <Text bold>{r.description || r.type}</Text>
861 </Text>
862 <Text dimColor wrap="truncate-end">
863 {' '}
864 {r.type} · {runMeta(r, words)}
865 </Text>
866 <Text wrap="truncate-end">
867 {' '}
868 {progress === null ? <Text dimColor>{textBar(ctxPercent(r) / 100, barW)}</Text> : <Text color={color}>{textBar(progress, barW)}</Text>}
869 <Text dimColor>
870 {' '}
871 {steps ? steps + ' · ' : ''}
872 {runStats(r, now)}
873 </Text>
874 </Text>
875 </Box>
876 )
877 }
878 const limitLine = (r: (typeof limitRows)[number]) => (
879 <Text key={`lim-${r.label}`} wrap="truncate-end">
880 {r.label.padEnd(15)}{' '}
881 {r.window ? (
882 <Text>
883 {textBar(r.window.percentUsed / 100, barW)} {Math.round(r.window.percentUsed)}%
884 </Text>
885 ) : (
886 <Text dimColor>{s().noPlan}</Text>
887 )}
888 <Text dimColor>
889 {' '}
890 {fmtTokens(r.local.tokens)} · ≈{fmtCost(r.local.costUsd)}
891 </Text>
892 </Text>
893 )
894 return (
895 <Box flexDirection="column">
896 {title ? <Text bold>{title}</Text> : null}
897 <Text dimColor>
898 {s().cost} ≈{fmtCost(t.cost)} · {s().tokens} {fmtTokens(t.tokens)} · {s().time} {fmtTime(t.time)}
899 </Text>
900 {turn ? (
901 <Text wrap="truncate-end">
902 <Text color={turn.status === 'failed' ? 'red' : turn.status === 'done' ? 'green' : 'magenta'}>{statusGlyph(turn.status)}</Text> {turnHead}
903 <Text dimColor> · {turnLine}</Text>
904 </Text>
905 ) : null}
906 <Box flexDirection="row" gap={2} marginTop={1}>
907 {toggleLimits}
908 {toggleCompact}
909 {webButton}
910 </Box>
911 {webLine ? <Text dimColor wrap="truncate-end">{webLine}</Text> : null}
912 {panel.isLimitsOpen ? limitRows.map(limitLine) : null}
913 {isEmpty ? <Text dimColor>{s().empty}</Text> : null}
914 {panel.isCompact ? (
915 <Text wrap="truncate-end">
916 {[...running, ...finished].map(r => (r.status === 'running' ? '● ' : r.status === 'done' ? '✓ ' : '✗ ')).join('')}
917 <Text dimColor>{planned.map(() => '◷ ').join('')}</Text>
918 </Text>
919 ) : (
920 <Box flexDirection="column" marginTop={1}>
921 {running.length > 0 ? <Text dimColor>{`${s().running} · ${running.length}`}</Text> : null}
922 {running.map(row)}
923 {finished.length > 0 ? toggleFinished : null}
924 {panel.isFinishedOpen ? finished.map(row) : null}
925 {planned.length > 0 ? <Text dimColor>{`${s().planned} · ${planned.length}`}</Text> : null}
926 {planned.map(p => (
927 <Text key={`plan-${p.n}`} dimColor wrap="truncate-end">
928 ◷ {p.n}. {p.title}
929 {p.after.length ? ` · ${s().after} ${p.after.join(', ')}` : ''}
930 </Text>
931 ))}
932 </Box>
933 )}
934 </Box>
935 )
936 })
937
938 // ---------------------------------------------------------------------------
939 // The job bar above the prompt
940
941 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
942 if (e.props.hasSurvey) return next(e)
943 const flow = await read($, flowAtom)
944 const all = await read($, runsAtom)
945 const panel = await read($, panelAtom)
946 const limits = await read($, limitsAtom)
947 const runs = visible(flow, all)
948 const running = runs.filter(r => r.status === 'running').length
949 const turn = await read($, turnAtom)
950 const shownTurn = turn && turn.startedAt > panel.hiddenAt ? turn : null
951 // A finished job gives way to the next prompt.
952 const isFlowOver = !!flow?.finished && !!shownTurn && shownTurn.startedAt > flow.updatedAt
953 const shownFlow = flow && flow.updatedAt > panel.hiddenAt && !isFlowOver ? flow : null
954
955 const ui = $.ui.resolve(e)
956 const { Box, Text, Button } = ui
957 // Plan limits sit at the right edge, beside the crew count: a colored ring
958 // per window on surfaces that draw pictures, a colored percent in the terminal.
959 const pairs = limitPairs(limits.windows)
960 const limitRings = (u: typeof ui) =>
961 'Svg' in u
962 ? pairs.map(([k, pct]) => (
963 <Box key={`lim-${k}`} flexDirection="row" alignItems="center" gap={1}>
964 <u.Svg source={ringSvg(pct)} alt={`${k} ${pct}% used`} width={RING} height={RING} />
965 <Text dimColor>{`${k} ${pct}%`}</Text>
966 </Box>
967 ))
968 : limitText()
969 const termColor = (pct: number) => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'blue')
970 const limitText = () =>
971 pairs.map(([k, pct]) => (
972 <Text key={`lim-${k}`} dimColor={pct < 70} color={termColor(pct)}>{`◔ ${k} ${pct}%`}</Text>
973 ))
974 const dismissBar = (
975 <Button key="hide" plain role="dismiss" label="✕" onPress={() => update($, panelAtom, p => ({ ...p, hiddenAt: Date.now() }))} />
976 )
977
978 // No job and no subagent: the bar follows the prompt itself.
979 if (!shownFlow && running === 0) {
980 if (!shownTurn) return next(e)
981 const now = Math.max(await read($, nowAtom), shownTurn.startedAt)
982 const prompt = shownTurn.prompt || s().thisPrompt
983 const word = turnWord(shownTurn)
984 const stats = turnStats(shownTurn, now)
985 const panelButton = <Button key="crew" plain label={runs.length ? `×${runs.length}` : s().appName} onPress={() => void togglePane($)} />
986
987 if (e.surface !== 'terminal' && 'Svg' in ui) {
988 const { Svg } = ui
989 const color = shownTurn.status === 'failed' ? BAD : shownTurn.status === 'done' ? OK : ACCENT
990 const cols = e.props.bodyColumns || 100
991 const reserved = Math.min(28, prompt.length) + word.length + stats.length + pairs.length * 10 + 22
992 const barW = Math.max(60, Math.min(600, (cols - reserved) * 8))
993 return (
994 <Box flexDirection="row" alignItems="center" gap={1}>
995 <Text key="title" bold wrap="truncate-end">
996 <Text color={color}>{statusGlyph(shownTurn.status)}</Text> {clip(prompt, 28)}
997 </Text>
998 <Svg
999 key="segs"
1000 source={segmentsSvg(barW, turnSegments(shownTurn), color, shownTurn.status === 'running')}
1001 alt={`${word}, ${stats}`}
1002 width={barW}
1003 height={SEG_H}
1004 />
1005 <Text key="label" bold color={color}>
1006 {word}
1007 </Text>
1008 <Text key="stats" dimColor wrap="truncate-end">
1009 {stats}
1010 </Text>
1011 <Box key="spacer" flexGrow={1} />
1012 {limitRings(ui)}
1013 {panelButton}
1014 {dismissBar}
1015 </Box>
1016 )
1017 }
1018
1019 const cols = e.props.bodyColumns || 80
1020 const promptW = Math.max(8, Math.min(28, Math.floor(cols / 4)))
1021 const width = Math.max(6, Math.min(16, cols - promptW - stats.length - 24))
1022 const color = shownTurn.status === 'failed' ? 'red' : shownTurn.status === 'done' ? 'green' : 'magenta'
1023 return (
1024 <Box flexDirection="row" gap={1}>
1025 <Text color={color}>{statusGlyph(shownTurn.status)}</Text>
1026 <Text bold wrap="truncate-end">
1027 {clip(prompt, promptW)}
1028 </Text>
1029 <Text color={color}>{pulseBar(shownTurn, now, width)}</Text>
1030 <Text bold>{word}</Text>
1031 <Text dimColor wrap="truncate-end">
1032 {stats}
1033 </Text>
1034 <Box flexGrow={1} />
1035 {limitText()}
1036 {panelButton}
1037 {dismissBar}
1038 </Box>
1039 )
1040 }
1041 const crew = runs.length + pending(shownFlow, runs).length
1042 const title = shownFlow?.title ?? s().appName
1043 const label = shownFlow ? flowLabel(shownFlow, running) : `${running} ${s().busy}`
1044 const ratio = shownFlow ? flowRatio(shownFlow) : 0
1045 const crewButton = <Button key="crew" plain label={`×${crew}`} onPress={() => void togglePane($)} />
1046
1047 if (e.surface !== 'terminal' && 'Svg' in ui) {
1048 const { Svg } = ui
1049 const color = shownFlow?.finished ? OK : ACCENT
1050 const cols = e.props.bodyColumns || 100
1051 const reserved = Math.min(28, title.length) + label.length + pairs.length * 10 + 22
1052 const barW = Math.max(60, Math.min(900, (cols - reserved) * 8))
1053 return (
1054 <Box flexDirection="row" alignItems="center" gap={1}>
1055 <Text key="title" bold wrap="truncate-end">
1056 <Text color={color}>●</Text> {clip(title, 28)}
1057 </Text>
1058 <Svg
1059 key="segs"
1060 source={segmentsSvg(barW, shownFlow ? segments(shownFlow, runs) : [], color, running > 0)}
1061 alt={`${label}, ${Math.round(ratio * 100)}%`}
1062 width={barW}
1063 height={SEG_H}
1064 />
1065 <Text key="label" bold color={color}>
1066 {label}
1067 </Text>
1068 <Text key="pct" dimColor>{`${Math.round(ratio * 100)}%`}</Text>
1069 <Box key="spacer" flexGrow={1} />
1070 {limitRings(ui)}
1071 {crewButton}
1072 {shownFlow ? dismissBar : null}
1073 </Box>
1074 )
1075 }
1076
1077 const cols = e.props.bodyColumns || 80
1078 const titleW = Math.max(8, Math.min(28, Math.floor(cols / 4)))
1079 const width = Math.max(6, Math.min(30, cols - titleW - 40))
1080 return (
1081 <Box flexDirection="row" gap={1}>
1082 <Text color={shownFlow?.finished ? 'green' : 'magenta'}>⛵</Text>
1083 <Text bold wrap="truncate-end">
1084 {clip(title, titleW)}
1085 </Text>
1086 <Text color={shownFlow?.finished ? 'green' : 'magenta'}>{textBar(ratio, width)}</Text>
1087 <Text bold>{label}</Text>
1088 <Text dimColor>{`${Math.round(ratio * 100)}%`}</Text>
1089 <Box flexGrow={1} />
1090 {limitText()}
1091 {crewButton}
1092 {shownFlow ? dismissBar : null}
1093 </Box>
1094 )
1095 })
1096
1097}
1098src/flow.ts 95 lines1import { normalize } from './format.ts'
2import { PHASES } from './types.ts'
3import type { AgentRun, Flow, FlowView, Phase, PlannedTask, PlannedView, ProgressInput } from './types.ts'
4
5const DEFAULT_TITLE = 'Crew job'
6
7const isPhase = (p: unknown): p is Phase => typeof p === 'string' && (PHASES as readonly string[]).includes(p)
8
9const toCount = (n: unknown): number | undefined =>
10 typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.round(n)) : undefined
11
12/** Drops untitled tasks and nonsense dependency numbers. */
13export function cleanTasks(tasks: ProgressInput['tasks']): PlannedTask[] | undefined {
14 if (!Array.isArray(tasks)) return undefined
15 const kept = tasks.filter(t => typeof t?.title === 'string' && t.title.trim())
16 return kept.map((t, i) => {
17 const out: PlannedTask = {
18 title: (t.title ?? '').trim(),
19 after: [...new Set((t.after ?? []).filter(n => Number.isInteger(n) && n > 0 && n !== i + 1))].sort((a, b) => a - b),
20 }
21 if (t.agent?.trim()) out.agent = t.agent.trim()
22 if (t.model?.trim()) out.model = t.model.trim()
23 if (t.effort?.trim()) out.effort = t.effort.trim().toLowerCase()
24 return out
25 })
26}
27
28/**
29 * True when a progress call opens a new job instead of updating the current one:
30 * nothing is running yet, the last job closed, or the title changed.
31 */
32export function startsNewFlow(prev: Flow | null, input: ProgressInput): boolean {
33 if (!prev || prev.finished) return true
34 const title = input.title?.trim()
35 return title !== undefined && title !== '' && title !== prev.title
36}
37
38/** Applies one `progress` call. Omitted fields keep their previous values. */
39export function applyProgress(prev: Flow | null, input: ProgressInput, at: number): Flow {
40 const fresh = startsNewFlow(prev, input)
41 const base: Flow =
42 fresh || !prev
43 ? { title: DEFAULT_TITLE, total: 0, done: 0, phase: 'plan', finished: false, tasks: [], startedAt: at, updatedAt: at }
44 : prev
45 const tasks = cleanTasks(input.tasks) ?? base.tasks
46 const total = toCount(input.total) ?? (input.tasks ? tasks.length : base.total)
47 const doneRaw = toCount(input.done) ?? base.done
48 const done = input.finished ? Math.max(doneRaw, total) : total ? Math.min(total, doneRaw) : doneRaw
49 const phase: Phase = input.finished ? 'close' : isPhase(input.phase) ? input.phase : base.phase
50 return {
51 title: input.title?.trim() || base.title,
52 total,
53 done,
54 phase,
55 finished: input.finished === true,
56 tasks,
57 startedAt: base.startedAt,
58 updatedAt: at,
59 }
60}
61
62/** Runs that belong on screen: a new job hides runs that finished before it began. */
63export function visibleRuns(flow: Flow | null, runs: AgentRun[]): AgentRun[] {
64 if (!flow) return runs
65 return runs.filter(r => r.status === 'running' || (r.endedAt ?? r.lastActivityAt) >= flow.startedAt)
66}
67
68/** Planned tasks no run has picked up yet, matched by title. */
69export function pendingTasks(flow: Flow | null, runs: AgentRun[]): PlannedView[] {
70 if (!flow || flow.finished) return []
71 const started = new Set(runs.map(r => normalize(r.description)))
72 return flow.tasks.map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(normalize(t.title)))
73}
74
75export function viewFlow(flow: Flow | null, runs: AgentRun[]): FlowView | null {
76 if (!flow) return null
77 const running = runs.filter(r => r.status === 'running').length
78 const ratio = flow.finished ? 1 : flow.total ? flow.done / flow.total : 0
79 const shownPhase: Phase = flow.phase === 'plan' && running > 0 ? 'delegate' : flow.phase
80
81 // One segment per planned task: accepted ones first, then those in flight.
82 const segments: FlowView['segments'] = []
83 const total = flow.finished ? Math.max(flow.total, 1) : flow.total
84 const byTitle = new Map(runs.map(r => [normalize(r.description), r]))
85 const inFlight = flow.tasks.length
86 ? flow.tasks.filter(t => byTitle.get(normalize(t.title))?.status === 'running').length
87 : running
88 for (let i = 0; i < total; i++) {
89 if (flow.finished || i < flow.done) segments.push('done')
90 else if (i < flow.done + inFlight) segments.push('running')
91 else segments.push('todo')
92 }
93 return { ...flow, ratio, running, shownPhase, segments }
94}
95src/format.ts 40 lines1export const fmtTokens = (n: number): string =>
2 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.max(0, Math.round(n))}`
3
4export const fmtCost = (usd: number): string =>
5 usd >= 100 ? `$${Math.round(usd)}` : usd >= 10 ? `$${usd.toFixed(1)}` : `$${usd.toFixed(2)}`
6
7/** `m:ss`, or `h:mm:ss` past an hour. */
8export function fmtTime(ms: number): string {
9 const s = Math.max(0, Math.round(ms / 1000))
10 const h = Math.floor(s / 3600)
11 const m = Math.floor((s % 3600) / 60)
12 const ss = String(s % 60).padStart(2, '0')
13 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
14}
15
16/** A coarse countdown: `3d 4h`, `1h 20m`, `12m`. */
17export function fmtDuration(ms: number): string {
18 const m = Math.max(0, Math.ceil(ms / 60_000))
19 const d = Math.floor(m / 1440)
20 const h = Math.floor((m % 1440) / 60)
21 if (d) return `${d}d ${h}h`
22 if (h) return `${h}h ${m % 60}m`
23 return `${m}m`
24}
25
26/** Lowercase, punctuation-free form used to match task titles against run descriptions. */
27export const normalize = (s: string): string =>
28 s
29 .toLowerCase()
30 .replace(/[^\p{L}\p{N}]+/gu, ' ')
31 .trim()
32
33export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
34
35/** A text bar such as `▰▰▰▱▱`. */
36export const textBar = (ratio: number, width: number, on = '▰', off = '▱'): string => {
37 const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width)
38 return on.repeat(filled) + off.repeat(Math.max(0, width - filled))
39}
40src/i18n.ts 117 lines1/** Languages the panel speaks. */
2export type Lang = 'en' | 'ru'
3import type { Phase } from './types.ts'
4
5export const STRINGS = {
6 en: {
7 appName: 'Crew',
8 cost: 'Cost',
9 tokens: 'Tokens',
10 time: 'Time',
11 collapse: 'Collapse',
12 expand: 'Expand',
13 running: 'Running',
14 finished: 'Finished',
15 planned: 'Planned',
16 empty: 'No subagents yet. Give Claude a job big enough to split across a few helpers.',
17 waiting: 'Waiting for a Claude Code session…',
18 round: 'round',
19 error: 'error',
20 killed: 'stopped',
21 stalled: 'stalled',
22 after: 'after',
23 background: 'background',
24 tokensWord: 'tokens',
25 agents: 'agents',
26 live: 'live',
27 reconnecting: 'reconnecting…',
28 dismiss: 'Hide bar',
29 theme: 'Theme',
30 estimate: 'Costs are estimates from token counts and list prices, not your bill.',
31 phase: { plan: 'Plan', design: 'Design', delegate: 'Tasks', review: 'Review', close: 'Done' } satisfies Record<Phase, string>,
32 busy: 'running',
33 limits: 'Plan limits',
34 fiveHour: '5-hour session',
35 weekly: 'Weekly',
36 resetsIn: 'resets in',
37 startsNext: 'resets with your next message',
38 updated: 'updated',
39 justNow: 'just now',
40 ago: 'ago',
41 requests: 'requests',
42 lastHours: 'last 5 hours',
43 lastDays: 'last 7 days',
44 noPlan: 'no plan data',
45 working: 'Working',
46 answered: 'Done',
47 stopped: 'Stopped',
48 steps: 'steps',
49 tools: 'tools',
50 lastPrompt: 'Last prompt',
51 thisPrompt: 'This prompt',
52 openWeb: 'Open in browser',
53 stopWeb: 'Stop browser view',
54 webStarting: 'Starting browser view…',
55 webAt: 'Browser view',
56 limitsHint:
57 'Plan percentages come from Claude Code\'s status line (Claude.ai plans only). Run `luff-crew-monitor setup --statusline` to record them. Token and cost totals are counted from your local transcripts.',
58 },
59 ru: {
60 appName: 'Команда',
61 cost: 'Стоимость',
62 tokens: 'Токены',
63 time: 'Время',
64 collapse: 'Свернуть',
65 expand: 'Развернуть',
66 running: 'Работают',
67 finished: 'Завершены',
68 planned: 'Запланированы',
69 empty: 'Субагентов пока нет. Дайте Claude задачу, которую стоит разделить между помощниками.',
70 waiting: 'Ждём сессию Claude Code…',
71 round: 'раунд',
72 error: 'ошибка',
73 killed: 'остановлен',
74 stalled: 'завис',
75 after: 'после',
76 background: 'в фоне',
77 tokensWord: 'токенов',
78 agents: 'агентов',
79 live: 'онлайн',
80 reconnecting: 'переподключение…',
81 dismiss: 'Скрыть полосу',
82 theme: 'Тема',
83 estimate: 'Стоимость оценена по токенам и прайс-листу, это не счёт.',
84 phase: { plan: 'План', design: 'Дизайн', delegate: 'Задачи', review: 'Ревью', close: 'Готово' } satisfies Record<Phase, string>,
85 busy: 'в работе',
86 limits: 'Лимиты плана',
87 fiveHour: 'Сессия 5 часов',
88 weekly: 'Неделя',
89 resetsIn: 'сброс через',
90 startsNext: 'сброшен, начнётся со следующего сообщения',
91 updated: 'обновлено',
92 justNow: 'только что',
93 ago: 'назад',
94 requests: 'запросов',
95 lastHours: 'за 5 часов',
96 lastDays: 'за 7 дней',
97 noPlan: 'нет данных плана',
98 working: 'Работает',
99 answered: 'Готово',
100 stopped: 'Остановлено',
101 steps: 'шагов',
102 tools: 'инструментов',
103 lastPrompt: 'Последний запрос',
104 thisPrompt: 'Текущий запрос',
105 openWeb: 'Открыть в браузере',
106 stopWeb: 'Закрыть браузерный вид',
107 webStarting: 'Запускаем браузерный вид…',
108 webAt: 'Браузерный вид',
109 limitsHint:
110 'Проценты плана приходят из статус-строки Claude Code (только планы Claude.ai). Запустите `luff-crew-monitor setup --statusline`, чтобы их записывать. Токены и стоимость считаются по локальным транскриптам.',
111 },
112} as const
113
114export type Strings = (typeof STRINGS)[Lang]
115
116export const stringsFor = (lang: Lang): Strings => STRINGS[lang]
117src/pricing.ts 126 lines1/**
2 * Cost estimation. Transcripts record tokens, not money, so every dollar figure
3 * this tool shows is an estimate from the public list prices below.
4 * Source: https://docs.claude.com/en/docs/about-claude/pricing (USD per million tokens).
5 */
6
7export interface Usage {
8 input_tokens?: number
9 output_tokens?: number
10 cache_read_input_tokens?: number
11 cache_creation_input_tokens?: number
12 cache_creation?: {
13 ephemeral_5m_input_tokens?: number
14 ephemeral_1h_input_tokens?: number
15 }
16}
17
18/** [input, 5m cache write, 1h cache write, cache read, output] in USD per MTok. */
19export type PriceRow = readonly [number, number, number, number, number]
20
21interface PriceRule {
22 match: RegExp
23 price: PriceRow
24 /** Price used when the request's prompt exceeds `threshold` tokens. */
25 long?: { threshold: number; price: PriceRow }
26}
27
28// Most specific first: the first match wins.
29const RULES: PriceRule[] = [
30 { match: /(fable|mythos)-5-1/, price: [10, 12.5, 20, 0.25, 50] },
31 { match: /fable|mythos/, price: [10, 12.5, 20, 1, 50] },
32 { match: /opus-5-5/, price: [4, 5, 8, 0.2, 20] },
33 { match: /opus-4-1|opus-4(?!-\d)|opus-4-2025/, price: [15, 18.75, 30, 1.5, 75] },
34 { match: /opus/, price: [5, 6.25, 10, 0.5, 25] },
35 { match: /sonnet-5-5/, price: [2, 2.5, 4, 0.1, 10] },
36 { match: /sonnet-5/, price: [2, 2.5, 4, 0.2, 10] },
37 { match: /sonnet/, price: [3, 3.75, 6, 0.3, 15] },
38 {
39 match: /haiku-5-5/,
40 price: [0.1, 0.125, 0.2, 0.01, 0.5],
41 long: { threshold: 100_000, price: [0.5, 0.625, 1, 0.05, 2.5] },
42 },
43 { match: /haiku-3-5|3-5-haiku/, price: [0.8, 1, 1.6, 0.08, 4] },
44 { match: /haiku/, price: [1, 1.25, 2, 0.1, 5] },
45]
46
47/** Used for unknown model ids: mid-range, so an estimate is never wildly off. */
48export const FALLBACK_PRICE: PriceRow = [3, 3.75, 6, 0.3, 15]
49
50let overrides: Array<{ match: RegExp; price: PriceRow }> = []
51
52/** Lets `~/.luff-crew/config.json` add or replace prices, keyed by a substring of the model id. */
53export function setPriceOverrides(table: Record<string, number[]> | undefined): void {
54 overrides = Object.entries(table ?? {})
55 .filter(([, row]) => Array.isArray(row) && row.length === 5 && row.every(n => typeof n === 'number' && n >= 0))
56 .map(([key, row]) => ({ match: new RegExp(escapeRegExp(key.toLowerCase())), price: row as unknown as PriceRow }))
57}
58
59const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60
61const promptTokens = (u: Usage): number =>
62 (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
63
64export function priceOf(model: string, usage?: Usage): PriceRow {
65 const id = model.toLowerCase()
66 const custom = overrides.find(o => o.match.test(id))
67 if (custom) return custom.price
68 const rule = RULES.find(r => r.match.test(id))
69 if (!rule) return FALLBACK_PRICE
70 if (rule.long && usage && promptTokens(usage) > rule.long.threshold) return rule.long.price
71 return rule.price
72}
73
74/** Estimated USD for one model request. */
75export function costOf(model: string, u: Usage): number {
76 const [input, write5m, write1h, read, output] = priceOf(model, u)
77 const written = u.cache_creation_input_tokens ?? 0
78 // The split by TTL is recorded on newer transcripts; older ones bill all writes at the 5m rate.
79 const w1h = Math.min(written, u.cache_creation?.ephemeral_1h_input_tokens ?? 0)
80 const w5m = written - w1h
81 return (
82 ((u.input_tokens ?? 0) * input +
83 w5m * write5m +
84 w1h * write1h +
85 (u.cache_read_input_tokens ?? 0) * read +
86 (u.output_tokens ?? 0) * output) /
87 1e6
88 )
89}
90
91/** Every token a request was billed for. */
92export const tokensOf = (u: Usage): number => promptTokens(u) + (u.output_tokens ?? 0)
93
94let windowOverrides: Record<string, number> = {}
95
96export function setContextWindowOverrides(table: Record<string, number> | undefined): void {
97 windowOverrides = Object.fromEntries(
98 Object.entries(table ?? {}).filter(([, n]) => typeof n === 'number' && n > 0).map(([k, n]) => [k.toLowerCase(), n]),
99 )
100}
101
102/** Context window size used for the `ctx %` figure. */
103export function contextWindowOf(model: string): number {
104 const id = model.toLowerCase()
105 for (const [key, size] of Object.entries(windowOverrides)) if (id.includes(key)) return size
106 if (/\[1m\]/.test(id)) return 1_000_000
107 if (/haiku|claude-3/.test(id)) return 200_000
108 // Generation 5 and later ship with a 1M window; earlier ones default to 200k.
109 const gen = /(?:opus|sonnet|fable|mythos)-(\d+)/.exec(id)
110 if (gen && Number(gen[1]) < 5) return 200_000
111 return 1_000_000
112}
113
114/** `claude-opus-5-5` → `Opus 5.5`, `claude-sonnet-5` → `Sonnet 5`. */
115export function modelLabel(id: string): string {
116 if (!id) return '—'
117 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
118 if (!m) {
119 const bare = id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
120 if (/^(fable|mythos|opus|sonnet|haiku)$/i.test(bare)) return bare.charAt(0).toUpperCase() + bare.slice(1).toLowerCase()
121 return bare || '—'
122 }
123 const [, family = '', major = '', minor] = m
124 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
125}
126src/types.ts 154 lines1/** Phases an orchestrator moves through while it runs a multi-agent job. */
2export const PHASES = ['plan', 'design', 'delegate', 'review', 'close'] as const
3export type Phase = (typeof PHASES)[number]
4
5export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
6export type Effort = (typeof EFFORTS)[number]
7
8/** One worker task the orchestrator plans to hand to a subagent. */
9export interface PlannedTask {
10 /** Must equal the Agent tool `description` so runs can be matched to tasks. */
11 title: string
12 /** Subagent type it will run as, e.g. `Explore` or `general-purpose`. */
13 agent?: string
14 /** Model family or id it is expected to run on, e.g. `opus` or `claude-sonnet-5`. */
15 model?: string
16 effort?: string
17 /** 1-based numbers of the tasks this one waits for. */
18 after: number[]
19}
20
21/** What the `progress` tool accepts. Every field is optional: omitted fields keep their value. */
22export interface ProgressInput {
23 title?: string
24 total?: number
25 done?: number
26 phase?: Phase
27 finished?: boolean
28 tasks?: Array<{ title?: string; agent?: string; model?: string; effort?: string; after?: number[] }>
29}
30
31/** The orchestrator's job as reported through `progress`. */
32export interface Flow {
33 title: string
34 total: number
35 done: number
36 phase: Phase
37 finished: boolean
38 tasks: PlannedTask[]
39 /** Epoch ms of the call that started this flow. */
40 startedAt: number
41 /** Epoch ms of the latest progress call. */
42 updatedAt: number
43}
44
45export type RunStatus = 'running' | 'done' | 'failed'
46
47/** Self-reported progress from a worker through the `step` tool. */
48export interface StepReport {
49 done: number
50 total: number
51 note?: string
52}
53
54/** One subagent run, rebuilt from its transcript. */
55export interface AgentRun {
56 /** Claude Code's agent id (the `agent-<id>.jsonl` file name). */
57 id: string
58 /** The parent's Agent tool_use id, when known. */
59 toolUseId?: string
60 /** Subagent type: `general-purpose`, `Explore`, a custom agent name, ... */
61 type: string
62 description: string
63 model: string
64 effort?: string
65 status: RunStatus
66 /** Why a run is marked failed: `error`, `killed`, `stalled`. */
67 failure?: string
68 background: boolean
69 startedAt: number
70 endedAt?: number
71 lastActivityAt: number
72 /** Tokens in the latest request: what the context window holds right now. */
73 contextTokens: number
74 contextMax: number
75 /** All tokens billed across the run's requests. */
76 tokens: number
77 costUsd: number
78 /** Model requests made so far. */
79 requests: number
80 /** 1 for the first run of a description, 2 for a retry, ... */
81 round: number
82 step?: StepReport
83 /** Nesting level: 1 for a subagent of the main thread, 2 for its own subagents. */
84 depth: number
85}
86
87export interface Totals {
88 costUsd: number
89 tokens: number
90 /** Wall-clock span from the first run's start to the last run's end (or now). */
91 timeMs: number
92 running: number
93 done: number
94 failed: number
95}
96
97export interface PlannedView extends PlannedTask {
98 /** 1-based position in the plan. */
99 n: number
100}
101
102export interface FlowView extends Flow {
103 /** Accepted share of the plan, 0..1. */
104 ratio: number
105 /** Workers running right now. */
106 running: number
107 /** Phase shown to the user: `plan` turns into `delegate` once workers start. */
108 shownPhase: Phase
109 /** Per-task state, for the segmented bar. */
110 segments: Array<'done' | 'running' | 'todo'>
111}
112
113export interface SessionInfo {
114 id: string
115 projectDir: string
116 cwd?: string
117 transcript: string
118}
119
120/** One plan usage window (5-hour session or weekly) on the limits panel. */
121export interface LimitWindowView {
122 /** Share of the plan limit used, 0..100, as Claude Code reports it; null when it has not. */
123 usedPct: number | null
124 /** Epoch ms when the window resets; null when unknown. */
125 resetsAt: number | null
126 /** Epoch ms the local totals below are counted from. */
127 since: number
128 /** Tokens, estimated cost and model requests in local transcripts since `since`. */
129 tokens: number
130 costUsd: number
131 requests: number
132}
133
134export interface LimitsView {
135 fiveHour: LimitWindowView
136 sevenDay: LimitWindowView
137 /** Epoch ms Claude Code last reported plan limits through the status line; null when never. */
138 reportedAt: number | null
139}
140
141/** Everything a view (dashboard, statusline, tool reply) needs, at one instant. */
142export interface Snapshot {
143 version: string
144 now: number
145 lang: 'en' | 'ru'
146 session: SessionInfo | null
147 flow: FlowView | null
148 agents: AgentRun[]
149 planned: PlannedView[]
150 totals: Totals
151 /** Plan usage limits; only views that poll local usage fill it in. */
152 limits?: LimitsView
153}
154hooks/draw.ts 242 lines1// Pure drawing for the Luff Crew mod: SVG sources for the surfaces that draw
2// pictures (desktop, VS Code, mobile) and the bits of text the terminal uses.
3// No engine calls here, so everything is testable as plain functions.
4
5import { clip, fmtCost, fmtTime, fmtTokens } from '../src/format.ts'
6import { modelLabel } from '../src/pricing.ts'
7import type { CrewLimitWindow, CrewRun } from '../types'
8
9export const ACCENT = '#5b6cf0'
10export const OK = '#2f9e66'
11export const BAD = '#d0453f'
12export const WARN = '#c98a12'
13
14// ---------------------------------------------------------------------------
15// Colors and the mascots: a round deckhand per agent type, gear per effort.
16
17const PALETTE = ['#E07A5F', '#3D9DD9', '#2BB3A0', '#9B7BE0', '#E0A83D', '#D96BA0', '#5FAF4F']
18const KNOWN: Record<string, number> = { 'general-purpose': 0, Explore: 2, Plan: 3 }
19
20export function typeColor(type: string): string {
21 const bare = type.replace(/^[^:]*:/, '')
22 const known = KNOWN[bare]
23 if (known !== undefined) return PALETTE[known] ?? ACCENT
24 let h = 0
25 for (const ch of bare) h = (h * 31 + ch.charCodeAt(0)) >>> 0
26 return PALETTE[h % PALETTE.length] ?? ACCENT
27}
28
29const INK = '#22201d'
30const WOOD = '#A87B4F'
31const GOLD = '#E8B931'
32
33type Put = (x: number, y: number, w: number, h: number, fill: string, group?: string) => void
34
35const GEAR: Record<string, (r: Put) => void> = {
36 // Deckhand with a mop: runs at double pace, the mop swishes.
37 low: r => {
38 r(6, 9, 12, 2, '#E5484D'); r(18, 9, 2, 1, '#E5484D'); r(19, 10, 2, 1, '#B83236')
39 r(21, 4, 1, 13, WOOD, 'prop'); r(19, 17, 5, 2, '#CFCFCB', 'prop')
40 r(19, 19, 1, 1, '#CFCFCB', 'prop'); r(21, 19, 1, 1, '#CFCFCB', 'prop'); r(23, 19, 1, 1, '#CFCFCB', 'prop')
41 },
42 // Sailor cap and an oar that rows.
43 medium: r => {
44 r(7, 6, 10, 3, '#F4F4F1'); r(7, 8, 10, 1, '#2B3A67'); r(11, 5, 2, 1, '#E5484D')
45 r(21, 3, 1, 13, WOOD, 'prop'); r(20, 15, 3, 4, '#8A6038', 'prop')
46 },
47 // Navigator's hat and a spyglass that scans.
48 high: r => {
49 r(7, 6, 10, 2, '#2E2A27'); r(5, 8, 14, 1, INK); r(11, 6, 2, 1, GOLD)
50 r(19, 11, 5, 2, '#C9A227', 'prop'); r(19, 11, 1, 2, '#8C6F12', 'prop'); r(23, 11, 1, 2, '#9BD3F5', 'prop')
51 },
52 // Lookout's beanie and a lantern whose flame flickers.
53 xhigh: r => {
54 r(7, 7, 10, 3, '#C0392B'); r(7, 9, 10, 1, '#962D22'); r(11, 6, 2, 1, '#F4F4F1')
55 r(21, 7, 1, 2, INK); r(20, 9, 3, 1, INK); r(20, 10, 3, 4, GOLD); r(20, 14, 3, 1, INK)
56 r(21, 11, 1, 2, '#FF8A3D', 'flame')
57 },
58 // Captain's hat and a ship's wheel that turns.
59 max: r => {
60 r(6, 5, 12, 3, '#1F2A4A'); r(5, 8, 14, 1, INK); r(11, 6, 2, 1, GOLD)
61 r(20, 11, 3, 1, WOOD, 'prop'); r(20, 17, 3, 1, WOOD, 'prop'); r(18, 13, 1, 3, WOOD, 'prop'); r(24, 13, 1, 3, WOOD, 'prop')
62 r(19, 12, 1, 1, WOOD, 'prop'); r(23, 12, 1, 1, WOOD, 'prop'); r(19, 16, 1, 1, WOOD, 'prop'); r(23, 16, 1, 1, WOOD, 'prop')
63 r(21, 10, 1, 9, '#8A6038', 'prop'); r(17, 14, 9, 1, '#8A6038', 'prop'); r(21, 14, 1, 1, GOLD, 'prop')
64 },
65}
66
67/**
68 * One mascot as an SVG group on a 26×22 grid, placed at (x, y) and scaled.
69 * Periods of every motion divide one second, so the once-a-second redraw of a
70 * running row restarts them in step and nothing visibly jumps.
71 */
72export function mascot(x: number, y: number, scale: number, type: string, effort: string | undefined, opts: { running?: boolean; dim?: boolean } = {}): string {
73 const groups: Record<string, string[]> = { bd: [], la: [], lb: [], prop: [], flame: [] }
74 const r: Put = (px, py, w, h, fill, g = 'bd') => {
75 ;(groups[g] ??= []).push(`<rect x="${px}" y="${py}" width="${w}" height="${h}" fill="${fill}"/>`)
76 }
77 const body = typeColor(type)
78 r(7, 9, 10, 1, body); r(6, 10, 12, 1, body); r(5, 11, 14, 6, body); r(6, 17, 12, 1, body)
79 r(3, 13, 2, 3, body); r(19, 13, 2, 3, body)
80 r(8, 12, 2, 2, INK); r(14, 12, 2, 2, INK); r(8, 12, 1, 1, '#fff'); r(14, 12, 1, 1, '#fff')
81 r(11, 15, 2, 1, INK); r(6, 15, 2, 1, 'rgba(255,120,140,.45)'); r(16, 15, 2, 1, 'rgba(255,120,140,.45)')
82 r(6, 18, 2, 3, body, 'la'); r(12, 18, 2, 3, body, 'la'); r(9, 18, 2, 3, body, 'lb'); r(15, 18, 2, 3, body, 'lb')
83 const gear = effort ? GEAR[effort] : undefined
84 gear?.(r)
85 const g = (name: string): string => ((groups[name] ?? []).length ? `<g class="${name}">${(groups[name] ?? []).join('')}</g>` : '')
86 const cls = `m e-${gear ? effort : 'none'}${opts.running ? ' run' : ''}`
87 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${opts.dim ? 0.4 : 1}" shape-rendering="crispEdges"><g class="${cls}"><g class="bd">${(groups.bd ?? []).join('')}${g('prop')}${g('flame')}</g>${g('la')}${g('lb')}</g></g>`
88}
89
90// Graphics only: the engine gives a render hook no theme, so every word is an
91// engine Text element (themed by the app) and these SVGs use colors that read
92// on light and dark alike: a translucent grey track, saturated fills.
93const TRACK = 'fill="#8a8a8a" fill-opacity=".22"'
94
95const STYLE = `<style>
96.m g{transform-box:fill-box}
97.m.run .la{animation:hop .5s steps(1) infinite}.m.run .lb{animation:hop .5s steps(1) infinite -.25s}
98.m.run .bd{animation:bob .5s steps(1) infinite -.125s}
99.m.e-low.run .la,.m.e-low.run .lb{animation-duration:.25s}
100.m.e-low.run .prop{transform-origin:50% 0;animation:swish .5s ease-in-out infinite}
101.m.e-medium.run .prop{transform-origin:50% 30%;animation:row 1s ease-in-out infinite}
102.m.e-high.run .prop{animation:scan 1s steps(1) infinite}
103.m.e-xhigh.run .flame{animation:flicker .25s steps(1) infinite}
104.m.e-max.run .prop{transform-origin:center;animation:spin 1s linear infinite}
105.stripe{animation:march 1s linear infinite}
106@keyframes hop{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
107@keyframes swish{50%{transform:rotate(14deg)}}@keyframes row{50%{transform:rotate(-22deg)}}
108@keyframes scan{33%{transform:translate(1px,-1px)}66%{transform:translate(0,-2px)}}
109@keyframes flicker{50%{opacity:.35}}@keyframes spin{to{transform:rotate(360deg)}}
110@keyframes march{to{transform:translateX(8px)}}
111@media (prefers-reduced-motion: reduce){.m.run *,.stripe{animation:none!important}}
112</style>`
113
114export const svg = (w: number, h: number, body: string): string =>
115 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${STYLE}${body}</svg>`
116
117// ---------------------------------------------------------------------------
118// Row parts
119
120export const ctxPercent = (r: CrewRun): number => (r.contextMax ? Math.min(100, Math.round((r.contextTokens / r.contextMax) * 100)) : 0)
121export const elapsed = (r: CrewRun, now: number): number => (r.endedAt ?? Math.max(now, r.startedAt)) - r.startedAt
122
123/** The worker's own steps when it reports them; a finished run is full; else null (show context). */
124export function progressOf(r: CrewRun): number | null {
125 if (r.status === 'done') return 1
126 if (r.step?.total) return Math.min(1, r.step.done / r.step.total)
127 return null
128}
129
130export function runMeta(r: CrewRun, words: { round: string; failed: Record<string, string> }): string {
131 const parts = [r.effort ? `${modelLabel(r.model)} · ${r.effort}` : modelLabel(r.model)]
132 if (r.round > 1) parts.push(`${words.round} ${r.round}`)
133 if (r.status === 'failed') parts.push(words.failed[r.failure ?? 'error'] ?? words.failed.error ?? 'error')
134 return parts.join(' · ')
135}
136
137export const runStats = (r: CrewRun, now: number): string =>
138 `ctx ${ctxPercent(r)}% · ${fmtTokens(r.contextTokens)} · ≈${fmtCost(r.costUsd)} · ${fmtTime(elapsed(r, now))}`
139
140export const stepText = (r: CrewRun): string =>
141 r.step?.total ? `${r.step.done}/${r.step.total}${r.step.note ? ' · ' + r.step.note : ''}` : ''
142
143export const statusGlyph = (status: string): string => (status === 'running' ? '●' : status === 'done' ? '✓' : status === 'failed' ? '✗' : '◷')
144export const statusColor = (status: string, color: string): string => (status === 'done' ? OK : status === 'failed' ? BAD : status === 'running' ? color : '#8a8a8a')
145
146export const MASCOT_W = 44
147export const MASCOT_H = 38
148
149/** One mascot on its own, for a row's left edge. */
150export const mascotSvg = (type: string, effort: string | undefined, opts: { running?: boolean; dim?: boolean } = {}): string =>
151 svg(MASCOT_W, MASCOT_H, mascot(0, 2, 1.65, type, effort, opts))
152
153export const BAR_H = 6
154
155/** A thin progress bar: `ratio` filled in `color`, or null to draw the track alone. */
156export function barSvg(W: number, ratio: number | null, color: string, h = BAR_H): string {
157 const fillW = ratio === null ? 0 : Math.round(W * Math.max(0, Math.min(1, ratio)))
158 return svg(W, h, `<rect x="0" y="0" width="${W}" height="${h}" rx="${h / 2}" ${TRACK}/>${fillW ? `<rect x="0" y="0" width="${fillW}" height="${h}" rx="${h / 2}" fill="${color}"/>` : ''}`)
159}
160
161/** Fill color of a usage meter: calm, then amber past 70%, red past 90%. */
162export const meterColor = (pct: number): string => (pct >= 90 ? BAD : pct >= 70 ? WARN : ACCENT)
163
164export const STRIP_H = 32
165
166/** A row of small mascots for the compact view. */
167export function stripSvg(W: number, icons: Array<{ type: string; effort?: string | undefined; running: boolean; dim: boolean }>): string {
168 const step = 38
169 const fit = Math.max(1, Math.floor(W / step))
170 return svg(
171 Math.min(W, Math.max(step, icons.slice(0, fit).length * step)),
172 STRIP_H,
173 icons
174 .slice(0, fit)
175 .map((ic, i) => mascot(i * step, 0, 1.4, ic.type, ic.effort, { running: ic.running, dim: ic.dim }))
176 .join(''),
177 )
178}
179
180export const SEG_H = 12
181
182/** The job's segmented bar: one segment per planned task, the running ones striped. */
183export function segmentsSvg(W: number, segments: Array<'done' | 'running' | 'todo'>, color: string, isWorking: boolean): string {
184 const segs = segments.length ? segments : [isWorking ? 'running' : 'todo']
185 const gap = segs.length > 1 ? 3 : 0
186 const sw = (W - gap * (segs.length - 1)) / segs.length
187 const body = segs
188 .map((s, i) => {
189 const x = (i * (sw + gap)).toFixed(1)
190 const w = sw.toFixed(1)
191 if (s === 'done') return `<rect x="${x}" y="0" width="${w}" height="${SEG_H}" rx="3" fill="${color}"/>`
192 if (s === 'running') {
193 const stripes = Array.from({ length: Math.ceil(sw / 8) + 2 }, (_, k) => `<path d="M${Number(x) - 8 + k * 8} ${SEG_H}l7-${SEG_H}h3l-7 ${SEG_H}z" fill="${color}"/>`).join('')
194 return `<clipPath id="c${i}"><rect x="${x}" y="0" width="${w}" height="${SEG_H}" rx="3"/></clipPath><g clip-path="url(#c${i})"><rect x="${x}" y="0" width="${w}" height="${SEG_H}" fill="${color}" fill-opacity=".3"/><g class="stripe">${stripes}</g></g>`
195 }
196 return `<rect x="${x}" y="0" width="${w}" height="${SEG_H}" rx="3" ${TRACK}/>`
197 })
198 .join('')
199 return svg(Math.max(1, Math.round(W)), SEG_H, body)
200}
201
202/** `▰▰▰▱▱` for the terminal. */
203export const textBar = (ratio: number, width: number): string => {
204 const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width)
205 return '▰'.repeat(filled) + '▱'.repeat(Math.max(0, width - filled))
206}
207
208/** `5h 23% · wk 41%`, or '' with no reading yet. */
209export function limitsShort(windows: CrewLimitWindow[]): string {
210 const five = windows.find(w => w.kind === 'five_hour')
211 const week = windows.find(w => w.kind === 'seven_day')
212 return [five ? `5h ${Math.round(five.percentUsed)}%` : '', week ? `wk ${Math.round(week.percentUsed)}%` : ''].filter(Boolean).join(' · ')
213}
214
215export { clip }
216
217export const RING = 18
218
219/** A small circular meter for a usage window, colored by meterColor. */
220export function ringSvg(pct: number, size = RING): string {
221 const c = size / 2
222 const r = c - 2
223 const len = 2 * Math.PI * r
224 const fill = (Math.max(0, Math.min(100, pct)) / 100) * len
225 return svg(
226 size,
227 size,
228 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="#8a8a8a" stroke-opacity=".3" stroke-width="3"/>` +
229 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${meterColor(pct)}" stroke-width="3" stroke-linecap="round" stroke-dasharray="${fill.toFixed(2)} ${len.toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`,
230 )
231}
232
233/** The 5-hour and weekly windows, in that order, as [label, percent]. */
234export function limitPairs(windows: CrewLimitWindow[]): Array<[string, number]> {
235 const out: Array<[string, number]> = []
236 const five = windows.find(w => w.kind === 'five_hour')
237 const week = windows.find(w => w.kind === 'seven_day')
238 if (five) out.push(['5h', Math.round(five.percentUsed)])
239 if (week) out.push(['wk', Math.round(week.percentUsed)])
240 return out
241}
242types/index.d.ts 117 lines1// State contract of the Luff Crew mod: every value it keeps in `$.state`.
2
3export type CrewPhase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
4
5export type CrewTask = {
6 title: string
7 agent?: string
8 model?: string
9 effort?: string
10 after: number[]
11}
12
13export type CrewFlow = {
14 title: string
15 total: number
16 done: number
17 phase: CrewPhase
18 finished: boolean
19 tasks: CrewTask[]
20 startedAt: number
21 updatedAt: number
22}
23
24export type CrewStep = { done: number; total: number; note?: string }
25
26export type CrewRun = {
27 /** The subagent's id, or the Agent call's id until the engine names one. */
28 id: string
29 agentId?: string
30 type: string
31 description: string
32 model: string
33 effort?: string
34 status: 'running' | 'done' | 'failed'
35 failure?: string
36 startedAt: number
37 endedAt?: number
38 contextTokens: number
39 contextMax: number
40 tokens: number
41 costUsd: number
42 requests: number
43 round: number
44 step?: CrewStep
45}
46
47export type CrewPanel = {
48 isCompact: boolean
49 isFinishedOpen: boolean
50 isLimitsOpen: boolean
51 /** The job bar was hidden at this time; a later progress call or prompt shows it again. */
52 hiddenAt: number
53 /** Job titles the pane already opened itself for. */
54 autoOpenedFor: string[]
55}
56
57export type CrewLimitWindow = {
58 /** `five_hour`, `seven_day`, or a gateway's `spend_limit`. */
59 kind: string
60 percentUsed: number
61 /** Epoch ms; 0 when unknown. */
62 resetsAt: number
63}
64
65export type CrewLimits = {
66 windows: CrewLimitWindow[]
67 /** Epoch ms of the last reading; 0 before the first. */
68 measuredAt: number
69}
70
71/** The main loop's current (or last) turn: one per prompt, subagents or not. */
72export type CrewTurn = {
73 turnId: string
74 /** The prompt that started it, clipped; "" for a continuation. */
75 prompt: string
76 status: 'running' | 'done' | 'failed'
77 /** `stopped` or `error` when it did not end with an answer. */
78 failure?: string
79 startedAt: number
80 endedAt?: number
81 /** Model requests so far. */
82 steps: number
83 /** Tool calls the model asked for so far. */
84 tools: number
85 lastTool?: string
86 tokens: number
87 costUsd: number
88}
89
90/** The optional browser dashboard, while one runs for this session. */
91export type CrewWeb = {
92 status: 'starting' | 'running'
93 url: string
94 startedAt: number
95}
96
97/** Tokens and estimated cost the mod saw, per hour bucket (epoch hour → totals). */
98export type CrewUsageBuckets = Record<string, { tokens: number; costUsd: number; requests: number }>
99
100declare module 'claude-code' {
101 interface PluginState {
102 'luff-crew-monitor': {
103 flow: CrewFlow | null
104 runs: CrewRun[]
105 turn: CrewTurn | null
106 web: CrewWeb | null
107 panel: CrewPanel
108 now: number
109 limits: CrewLimits
110 /** What this session counted, per hour. */
111 usage: CrewUsageBuckets
112 /** What other sessions counted, per hour, read from `$.store`. */
113 others: CrewUsageBuckets
114 }
115 }
116}
117