SLOPSHOPPER

luff-crew-monitor

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…

newpanebandguardcommandtoast
v1.4.1MITupdated 2026-10-10tanwar-harsh/luff-crew-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · luff-crew-monitor
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ Cost ≈$0.00 · Tokens 0 · Time 0:00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▾ Plan limits Collapse Open in browser ⎿ Read 6 lines │ ┃ 5-hour session ▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱ 31% 0 · ≈… ⏺ Update(src/auth.ts) │ ┃ Weekly no plan data 0 · ≈$0.00 ⎿ Added 2 lines, removed 1 line │ ┃ No subagents yet. Give Claude a job big ⏺ Bash(bun test) │ ┃ enough to split across a few helpers. ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ luff-crew-monitor: Luff Crew panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
Cost ≈$0.00 · Tokens 0 · Time 0:00 ▾ Plan limits Collapse Open in browser 5-hour session ▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱ 31% 0 · ≈$0.00 Weekly no plan data 0 · ≈$0.00 No subagents yet. Give Claude a job big enough to split across a few helpers.
README

Luff Crew Monitor

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%">

  • Job bar on every prompt: while Claude works on anything, the bar above the prompt shows what you asked, one segment per model request, tool calls (and the one running now), elapsed time and estimated cost, then Done or Stopped. When Claude splits the work across subagents it becomes the job bar: title, phase, accepted tasks out of planned (one segment per task), percent done and crew size. Your 5-hour and weekly limits sit at its right edge as colored rings.
  • Agents panel: Running, Finished and Planned sections with cost, token and time totals. Each subagent gets a pixel deckhand whose gear shows its effort level: mop, oar, spyglass, lantern or ship's wheel.
  • Worker steps: subagents report their own steps (2/5 · running tests). A subagent that doesn't report shows its context fill in grey instead.
  • Works with any subagent: built-in, custom or plugin agents, foreground or background, retries counted as rounds.
  • Plan limits: your 5-hour session and weekly usage, with % used and when each resets, above the agents. Under each meter, the tokens, estimated cost and requests counted from your local transcripts for that window.
  • /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.
  • Optional browser view: /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.
  • Private by design: everything stays on your machine. No telemetry; the browser view listens on localhost only and only while you have it on.

Install

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.

Update

Already installed? Pull the latest version, then restart Claude (or run /reload-plugins):

claude plugin update luff-crew-monitor@luff-crew

Without the mod: MCP server and browser dashboard

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.

Use

  1. Send Claude anything. The bar above the prompt follows the turn: steps, tool calls, time and estimated cost.
  2. Give it a job big enough to split across a few subagents and the bar becomes the job bar. Type /agents-info to open the panel.

Browser view (optional)

Inside Claude is the default and needs nothing. If you'd also like the crew in a browser tab:

CommandWhat it does
/agents-webStarts 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 stopStops 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.

Plan limits

The panel at the top tracks the two Claude.ai plan windows: the 5-hour session and the weekly limit.

  • % used and reset time come straight from Claude Code (the same figures as its status line), after your first reply in a session. The meters turn amber past 70% and red past 90%.
  • On the job bar the same two windows sit at the right edge, beside the crew count, as small rings in the same colors. The terminal shows them as colored text (◔ 5h 38%).
  • Tokens, estimated cost and requests for each window are counted from every model request the mod sees, across your sessions and their subagents.

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.

Tools

The mod gives Claude two tools. The note it adds to the system prompt tells Claude when to call them.

ToolWho calls itWhat it does
mcp__luff-crew-monitor__progressOrchestratorReports 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__stepSubagentReports 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.

Configure

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" } } } }

How it works

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.
  • A 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.

Good to know

  • Costs are estimates. They're computed from token counts and public list prices (including cache reads, and 5-minute vs 1-hour cache writes), not taken from your bill. Subscription plans aren't billed per token at all.
  • Plan limits appear after Claude's first reply in a session. They're Claude.ai plans only.
  • Where it draws: the desktop app's Code tab, the VS Code extension and the terminal. In the terminal's main screen the panel opens inline. In the fullscreen layout it docks as a sidebar.

Standalone CLI and MCP server

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.

Development

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/.
  • To work on the mod live, start Claude Code with claude --plugin-dir .. Edits reload when a turn ends.
  • The CLI bundle in dist/ is committed so installs need no build step. Run npm run build before committing source changes.

License

MIT © 2026 Harsh Tanwar

Source 8 files
hooks/register.tsx 1098 lines
1// 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}
1098
src/flow.ts 95 lines
1import { 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}
95
src/format.ts 40 lines
1export 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}
40
src/i18n.ts 117 lines
1/** 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]
117
src/pricing.ts 126 lines
1/**
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}
126
src/types.ts 154 lines
1/** 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}
154
hooks/draw.ts 242 lines
1// 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}
242
types/index.d.ts 117 lines
1// 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