One pane for every subagent (tabs, details, timeline), the files they read, changed and committed (history, diffs, conflicts), and the session's usage.

A Claude Code mod: one pane (/crew) for the session's subagents, the files they touch, and the session's usage and cost. The repository README has screenshots and the full description.
Each section can be switched on or off in the settings.
/compact button. Each meter can be hidden on its own. Warnings when a limit passes 80 % and 95 %, and when the context passes 85 %.PRICES table in hooks/register.tsx.$.agent.list() knows), in Active and Finished tabs, each a pixel crab in its type's costume and colour. A row's bar shows the agent's own to-do list (TodoWrite, TaskCreate/TaskUpdate) where it keeps one, read from its calls at no token cost; otherwise it is an estimate from run time and model requests, kept short of 90 %. Steps reported through savvy-progress's step tool count as well. Once the agent ends, the bar is full in green, red or grey. Expanding a row shows the task, recent actions, files and the final answer. Clear finished, under the Finished tab, removes finished agents; their cost stays in the totals.turn.start to turn.complete).HEAD; double-clicking opens it in the system's default app. A search line narrows it; a file just touched shimmers, and its folders open while it does. Folders start collapsed and open on a click; a double-click shows only that folder, and the breadcrumbs under the search (main folder and branch) lead back up. ⊞/⊟ expand or collapse every folder; git is read again after changes and every ten seconds while the pane is open. Filters: Activity (default) for what the agents read and changed, Changes for everything git reports as changed, or All files under the root. Two agents changing the same file within ten minutes is marked as a conflict until it is committed.Reads come from Read, Grep results and shell readers (cat, head, sed, rg, Get-Content and others); writes from Edit, Write and NotebookEdit. Git is read after writes and commands that may change the tree, and every ten seconds while the pane is open.
/crew-settings, or the Settings button in the pane, opens the settings with the groups Sections, Usage, General, Notifications and Version. Changes apply immediately and are the same values as in /config or pluginConfigs.agent-crew.options in ~/.claude/settings.json.
| Option | Default | Description |
|---|---|---|
showUsage | true | Usage section |
showTotals | false | Cost section |
showAgents | true | Subagents section |
showTimeline | true | Timeline section |
timelineMain | true | The main chat's turns in a row at the top of the timeline |
showFiles | true | Files section |
usageContext | true | Context meter |
usageFiveHour | true | 5-hour limit meter |
usageWeek | true | Weekly limit meters |
bandAgents | true | Above the prompt: running, finished and failed subagents and conflicts; a click opens the pane |
bandProgress | false | Above the prompt: each running subagent with its bar |
bandUsage | false | Above the prompt: context and limits, with the time until they reset, and a /compact button |
statusLine | true | The status line entry with the number of running subagents |
autoOpen | true | Open the pane on the first subagent of a session |
autoOpenFiles | true | Open the pane on the first file read or changed in a session |
language | auto | auto, de or en |
crabMotion | smooth | smooth: on the desktop the pane redraws four times a second while an agent runs, so the crabs walk evenly; light: once a second, the crabs' step starting over each time |
notifyAgents | background | off, failures, background, all |
notifyVia | toast | toast, or system for the terminal's or system's notification |
limitWarnings | true | Warnings at 80 % and 95 % of a limit and at 85 % context |
warnConflicts | true | Notice on conflicting edits |
checkUpdates | true | Daily check for a new version; the settings pane installs it |
claude plugin marketplace add jduerrmann/agent-crew
claude plugin install agent-crew@agent-crew
hooks/crab.ts).hooks/files.ts).Both are MIT licensed; see NOTICE. agent-crew itself is licensed under PolyForm Noncommercial 1.0.0.
hooks/register.tsx 3744 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { CrewUpdate, CrewUsage } from '../types'
5import type { CrewFile, CrewGit, CrewGitFile, CrewKind, CrewLog, CrewMainTurn, CrewPanel, CrewReport, CrewRun, CrewStatus, CrewTab, CrewTask } from '../types'
6import { CLAY, FLIP_MS, FONT, crab, fitText, svg, textWidth, xml } from './crab'
7import type { FileRowSpec, FileRowsProps, Seg } from './filerows'
8import type { SearchBoxProps } from './searchbox'
9import { applyNumstat, filesBeneath, folderKeys, folderOf, inside, isNewer, keyOf, mayChange, openCommand, parseStatus, posix, rawGithubUrl, readTargets, resolve, rowsUnder, treeRows, underFolder, useDrives, visibleRows } from './files'
10import type { TreeRow } from './files'
11import type { HotSeg, HotspotProps } from './hotspot'
12import { applyTask, holderOf, isTodoTool, taskIdOf, tasksReport, todoReport } from './progress'
13import type { Tab, TabsProps } from './tabs'
14
15// One pane for the session's crew and what it touched. Above: every subagent,
16// however it started (the Agent tool, a fork, a teammate, a workflow script's
17// agent(), one nested in another, one only `$.agent.list()` knows), in two tabs,
18// each with a details view. Below: the files read, changed and committed, as a
19// tree with git status, and which agents touched them.
20
21const runs = atom({ plugin: 'agent-crew', key: 'runs' } as const, [])
22const logs = atom({ plugin: 'agent-crew', key: 'logs' } as const, {})
23const PANEL_DEFAULT: CrewPanel = {
24 tab: 'active',
25 expanded: [],
26 isCompact: false,
27 hasAutoOpened: false,
28 fileFilter: 'activity',
29 isFilesCollapsed: false,
30 isTimelineOpen: true,
31}
32const panel = atom({ plugin: 'agent-crew', key: 'panel' } as const, PANEL_DEFAULT)
33const cleared = atom({ plugin: 'agent-crew', key: 'cleared' } as const, { cost: 0, tokens: 0 })
34const files = atom({ plugin: 'agent-crew', key: 'files' } as const, [])
35const git = atom({ plugin: 'agent-crew', key: 'git' } as const, null)
36const now = atom({ plugin: 'agent-crew', key: 'now' } as const, 0)
37const usage = atom({ plugin: 'agent-crew', key: 'usage' } as const, { context: { percent: null, window: 0 }, limits: [] })
38const diff = atom({ plugin: 'agent-crew', key: 'diff' } as const, null)
39const warned = atom({ plugin: 'agent-crew', key: 'warned' } as const, {})
40const settingsRev = atom({ plugin: 'agent-crew', key: 'settingsRev' } as const, 0)
41const mainCost = atom({ plugin: 'agent-crew', key: 'mainCost' } as const, { cost: 0, tokens: 0 })
42const tree = atom({ plugin: 'agent-crew', key: 'tree' } as const, null)
43const updateInfo = atom({ plugin: 'agent-crew', key: 'update' } as const, null)
44const typeColors = atom({ plugin: 'agent-crew', key: 'typeColors' } as const, {})
45const mainTurns = atom({ plugin: 'agent-crew', key: 'mainTurns' } as const, [])
46const tasks = atom({ plugin: 'agent-crew', key: 'tasks' } as const, [])
47const mainReport = atom({ plugin: 'agent-crew', key: 'mainReport' } as const, null)
48
49// The plugin's options, set by register on every (re)load.
50let opts: Record<string, unknown> = {}
51
52const MAX_HISTORY = 12
53const MAX_DIFF = 30_000
54/** An untracked file larger than this is not read for its card. */
55const MAX_SOURCE_BYTES = 256 * 1024
56/** Two agents changing one file within this window is a conflict. */
57const CONFLICT_MS = 10 * 60_000
58
59const PANE = 'agent-crew'
60const MAX_RUNS = 300
61/** The main chat's turns kept for the timeline: the newest this many. */
62const MAX_MAIN_TURNS = 200
63const MAX_CLEARED = 2000
64const MAX_FILES = 400
65const MAX_LOG = 40
66const MAX_TEXT = 4000
67const FRESH_MS = 8000
68
69const READ_COLOR = '#a855f7'
70const WRITE_COLOR = '#f97316'
71const COMMIT_COLOR = '#22c55e'
72const DELETE_COLOR = '#ef4444'
73const NEW_COLOR = '#3b82f6'
74
75// ---------------------------------------------------------------------------
76// Language: the `language` option, else Claude Code's `language` setting, else
77// the process locale; English when nothing says German.
78
79type Lang = 'de' | 'en'
80
81const STRINGS = {
82 de: {
83 pane: 'Crew',
84 cost: 'Kosten',
85 tokens: 'Tokens',
86 time: 'Zeit',
87 collapse: 'Kompakt',
88 expand: 'Ausklappen',
89 active: 'Aktiv',
90 finished: 'Fertig',
91 clear: 'Fertige entfernen',
92 noActive: 'Gerade läuft kein Subagent.',
93 noDone: 'Noch kein Subagent fertig.',
94 failed: 'Fehler',
95 stopped: 'gestoppt',
96 idle: 'wartet',
97 steps: (n: number) => (n === 1 ? '1 Schritt' : `${n} Schritte`),
98 background: 'Hintergrund',
99 fork: 'Fork',
100 teammate: 'Teammate',
101 workflow: 'Workflow',
102 tokensWord: 'Tokens',
103 agentsCount: 'Agenten',
104 isRunning: 'läuft',
105 isFinished: 'fertig',
106 opened: 'Crew-Panel geöffnet.',
107 closed: 'Crew-Panel geschlossen.',
108 details: 'Details',
109 task: 'Auftrag',
110 recent: 'Zuletzt',
111 answer: 'Antwort',
112 copy: 'Antwort kopieren',
113 copied: 'Antwort kopiert.',
114 nothingYet: 'Noch keine Aktion aufgezeichnet.',
115 touched: (r: number, w: number) => `Dateien: ${r} gelesen, ${w} geändert`,
116 files: 'Dateien',
117 fileFilters: { activity: 'Aktivität', changed: 'Änderungen', all: 'Alle' },
118 noFiles: 'Noch keine Dateien gelesen oder geändert.',
119 legend: { read: 'gelesen', write: 'geändert', isNew: 'neu', commit: 'committet', delete: 'gelöscht' },
120 more: (n: number) => `… ${n} weitere`,
121 main: 'Hauptchat',
122 totalsTitle: 'Kosten',
123 search: 'Dateien suchen',
124 hits: (n: number) => `${n} Treffer`,
125 noHits: 'Keine Datei passt zur Suche.',
126 fresh: 'neu',
127 cached: 'aus Cache',
128 allHidden: 'Alle Abschnitte sind ausgeblendet.',
129 total: 'Gesamt',
130 subagents: (n: number) => `Subagenten (${n})`,
131 usage: 'Nutzung',
132 context: 'Kontext',
133 fiveHour: '5 Std.',
134 week: 'Woche',
135 resets: (span: string) => `zurück in ${span}`,
136 conflict: (file: string, names: string[]) => `⚠ ${file}: kurz nacheinander geändert von ${names.join(' und ')}`,
137 conflictLine: (names: string) => `⚠ Von mehreren Agenten geändert: ${names}`,
138 agentDone: (name: string, time: string) => `✓ ${name} ist fertig (${time})`,
139 agentFailed: (name: string) => `✗ ${name} ist fehlgeschlagen`,
140 agentStopped: (name: string) => `■ ${name} wurde gestoppt`,
141 limitWarn: (label: string, pct: number, reset: string) => `${label}-Limit bei ${pct} %${reset ? ` (${reset})` : ''}`,
142 contextWarn: (pct: number) => `Kontext zu ${pct} % voll: /compact schafft Platz`,
143 timeline: 'Zeitleiste',
144 focusOn: 'Seine Dateien hervorheben',
145 focusOff: 'Hervorhebung aufheben',
146 history: 'Verlauf',
147 updateAvailable: (v: string) => `agent-crew ${v} ist verfügbar: /crew-settings zum Aktualisieren`,
148 updateDone: (v: string) => `agent-crew ${v} installiert, die Plugins laden neu`,
149 updateFailed: (why: string) => `Update fehlgeschlagen: ${why}`,
150 updateBadge: (v: string) => `↑ ${v}`,
151 noHistory: 'Noch nichts aufgezeichnet.',
152 openIt: 'Öffnen',
153 openFailed: (name: string) => `${name} ließ sich nicht öffnen`,
154 histKind: { read: 'gelesen', write: 'geändert', commit: 'committet' },
155 changes: 'Änderungen',
156 newFile: 'Neue Datei',
157 noDiff: 'Kein Unterschied zu HEAD.',
158 isNew: 'neu',
159 isDeleted: 'gelöscht',
160 statusRunning: (n: number) => `${n} Subagent${n === 1 ? '' : 'en'} aktiv`,
161 bandDone: (n: number) => `${n} fertig`,
162 bandFailed: (n: number) => `${n} mit Fehler`,
163 bandDoneAgents: (n: number) => `${n} Subagent${n === 1 ? '' : 'en'} fertig`,
164 bandFailedAgents: (n: number) => `${n} Subagent${n === 1 ? '' : 'en'} mit Fehler`,
165 bandIdle: (n: number) => `${n} ${n === 1 ? 'wartet' : 'warten'}`,
166 bandIdleAgents: (n: number) => (n === 1 ? '1 Subagent wartet' : `${n} Subagenten warten`),
167 bandStopped: (n: number) => `${n} gestoppt`,
168 bandStoppedAgents: (n: number) => `${n} Subagent${n === 1 ? '' : 'en'} gestoppt`,
169 bandConflicts: (n: number) => `${n} Konflikt${n === 1 ? '' : 'e'}`,
170 bandOpen: 'Crew öffnen',
171 },
172 en: {
173 pane: 'Crew',
174 cost: 'Cost',
175 tokens: 'Tokens',
176 time: 'Time',
177 collapse: 'Compact',
178 expand: 'Expand',
179 active: 'Active',
180 finished: 'Finished',
181 clear: 'Clear finished',
182 noActive: 'No subagent is running.',
183 noDone: 'No subagent has finished yet.',
184 failed: 'error',
185 stopped: 'stopped',
186 idle: 'waiting',
187 steps: (n: number) => (n === 1 ? '1 step' : `${n} steps`),
188 background: 'background',
189 fork: 'fork',
190 teammate: 'teammate',
191 workflow: 'workflow',
192 tokensWord: 'tokens',
193 agentsCount: 'agents',
194 isRunning: 'running',
195 isFinished: 'finished',
196 opened: 'Crew panel opened.',
197 closed: 'Crew panel closed.',
198 details: 'Details',
199 task: 'Task',
200 recent: 'Recent',
201 answer: 'Answer',
202 copy: 'Copy answer',
203 copied: 'Answer copied.',
204 nothingYet: 'No action recorded yet.',
205 touched: (r: number, w: number) => `Files: ${r} read, ${w} changed`,
206 files: 'Files',
207 fileFilters: { activity: 'Activity', changed: 'Changes', all: 'All' },
208 noFiles: 'No files read or changed yet.',
209 legend: { read: 'read', write: 'changed', isNew: 'new', commit: 'committed', delete: 'deleted' },
210 more: (n: number) => `… ${n} more`,
211 main: 'main chat',
212 totalsTitle: 'Cost',
213 search: 'Search files',
214 hits: (n: number) => `${n} ${n === 1 ? 'hit' : 'hits'}`,
215 noHits: 'No file matches the search.',
216 fresh: 'new',
217 cached: 'cached',
218 allHidden: 'Every section is hidden.',
219 total: 'Total',
220 subagents: (n: number) => `Subagents (${n})`,
221 usage: 'Usage',
222 context: 'context',
223 fiveHour: '5h',
224 week: 'week',
225 resets: (span: string) => `resets in ${span}`,
226 conflict: (file: string, names: string[]) => `⚠ ${file}: changed close together by ${names.join(' and ')}`,
227 conflictLine: (names: string) => `⚠ Changed by several agents: ${names}`,
228 agentDone: (name: string, time: string) => `✓ ${name} finished (${time})`,
229 agentFailed: (name: string) => `✗ ${name} failed`,
230 agentStopped: (name: string) => `■ ${name} was stopped`,
231 limitWarn: (label: string, pct: number, reset: string) => `${label} limit at ${pct} %${reset ? ` (${reset})` : ''}`,
232 contextWarn: (pct: number) => `Context ${pct} % full: /compact makes room`,
233 timeline: 'Timeline',
234 focusOn: 'Highlight its files',
235 focusOff: 'Clear highlight',
236 history: 'History',
237 updateAvailable: (v: string) => `agent-crew ${v} is available: /crew-settings to update`,
238 updateDone: (v: string) => `agent-crew ${v} installed, plugins reloading`,
239 updateFailed: (why: string) => `Update failed: ${why}`,
240 updateBadge: (v: string) => `↑ ${v}`,
241 noHistory: 'Nothing recorded yet.',
242 openIt: 'Open',
243 openFailed: (name: string) => `Could not open ${name}`,
244 histKind: { read: 'read', write: 'changed', commit: 'committed' },
245 changes: 'Changes',
246 newFile: 'New file',
247 noDiff: 'No difference to HEAD.',
248 isNew: 'new',
249 isDeleted: 'deleted',
250 statusRunning: (n: number) => `${n} subagent${n === 1 ? '' : 's'} running`,
251 bandDone: (n: number) => `${n} done`,
252 bandFailed: (n: number) => `${n} failed`,
253 bandDoneAgents: (n: number) => `${n} subagent${n === 1 ? '' : 's'} done`,
254 bandFailedAgents: (n: number) => `${n} subagent${n === 1 ? '' : 's'} failed`,
255 bandIdle: (n: number) => `${n} waiting`,
256 bandIdleAgents: (n: number) => `${n} subagent${n === 1 ? '' : 's'} waiting`,
257 bandStopped: (n: number) => `${n} stopped`,
258 bandStoppedAgents: (n: number) => `${n} subagent${n === 1 ? '' : 's'} stopped`,
259 bandConflicts: (n: number) => `${n} conflict${n === 1 ? '' : 's'}`,
260 bandOpen: 'Open crew',
261 },
262} as const
263
264// Module scope is fine for these: session.start sets them again on every (re)load.
265let lang: Lang = 'en'
266let fold = false
267let home = ''
268let gitQueued = false
269let gitBusy = false
270let tick = 0
271// The flip book's timer, running only while an agent walks on a desktop pane.
272let flipTimer: { cancel: () => void } | undefined
273let desktopDrawnAt = -Infinity
274// Drawings of the pane and the band under way. A tick of the clock while one is
275// running would start the next before it ends: live, where each read crosses to
276// the host, a drawing can take longer than a flip, and none would ever finish.
277let drawing = 0
278// By the `crabMotion` option: `smooth`, a flip book of stills redrawn four times
279// a second; `light`, the crabs' own animation, which each redraw starts over.
280const flipAt = (at: number): number | undefined => (opts.crabMotion === 'light' ? undefined : at)
281const tr = () => STRINGS[lang]
282
283const isGerman = (v: unknown): boolean => typeof v === 'string' && /^(de|german|deutsch)/i.test(v.trim())
284
285async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
286 if (option === 'de' || option === 'en') return option
287 try {
288 const settings = (await $.settings.read()) as Record<string, unknown>
289 if (typeof settings.language === 'string' && settings.language.trim()) return isGerman(settings.language) ? 'de' : 'en'
290 } catch {
291 // No settings: fall through to the locale.
292 }
293 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
294 if (locale) return isGerman(locale) ? 'de' : 'en'
295 // Windows sets no LANG: the runtime's own locale follows the system's.
296 try {
297 return isGerman(new Intl.DateTimeFormat().resolvedOptions().locale) ? 'de' : 'en'
298 } catch {
299 return 'en'
300 }
301}
302
303// State written by an older version of the mod lacks newer fields.
304const readPanel = async ($: EngineInterface): Promise<CrewPanel> => ({ ...PANEL_DEFAULT, ...(await read($, panel)) })
305const setPanel = ($: EngineInterface, fn: (p: CrewPanel) => Partial<CrewPanel>) =>
306 update($, panel, prev => {
307 const p = { ...PANEL_DEFAULT, ...prev }
308 return { ...p, ...fn(p) }
309 })
310
311// ---------------------------------------------------------------------------
312// Money, models, formatting.
313
314// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
315// The engine reports tokens, not money, so the cost is an estimate.
316const PRICES: [RegExp, [number, number, number, number]][] = [
317 [/fable|mythos/, [10, 50, 0.25, 12.5]],
318 [/opus-5-5/, [4, 20, 0.2, 5]],
319 [/opus/, [5, 25, 0.5, 6.25]],
320 [/sonnet/, [2, 10, 0.2, 2.5]],
321 [/haiku/, [1, 5, 0.1, 1.25]],
322]
323
324type Usage = {
325 input_tokens: number
326 output_tokens: number
327 cache_read_input_tokens: number
328 cache_creation_input_tokens: number
329}
330
331const priceOf = (model: string): [number, number, number, number] =>
332 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
333
334const costOf = (model: string, u: Usage): number => {
335 const [i, o, r, w] = priceOf(model)
336 return (
337 ((u.input_tokens || 0) * i +
338 (u.output_tokens || 0) * o +
339 (u.cache_read_input_tokens || 0) * r +
340 (u.cache_creation_input_tokens || 0) * w) /
341 1e6
342 )
343}
344
345/** Everything a request carried: the context window's fill after it. */
346const sumOf = (u: Usage): number =>
347 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
348
349// Each request resends the whole context, mostly read from the cache. Counted
350// apart: what was new (input, output, cache writes), and what the cache served.
351const freshOf = (u: Usage): number => (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0)
352const cacheOf = (u: Usage): number => u.cache_read_input_tokens || 0
353
354const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
355
356const modelName = (id: string): string => {
357 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
358 const [, family = '', major = '', minor] = m ?? []
359 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
360 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
361}
362
363const fmtTokens = (n: number): string =>
364 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
365
366const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
367
368const fmtTime = (ms: number): string => {
369 const s = Math.max(0, Math.round(ms / 1000))
370 const h = Math.floor(s / 3600)
371 const m = Math.floor((s % 3600) / 60)
372 const ss = String(s % 60).padStart(2, '0')
373 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
374}
375
376const fmtAgo = (ms: number): string => {
377 const s = Math.max(0, Math.round(ms / 1000))
378 if (s < 60) return `${s}s`
379 if (s < 3600) return `${Math.floor(s / 60)}m`
380 return `${Math.floor(s / 3600)}h`
381}
382
383const oneLine = (s: string, max: number): string => {
384 const t = s.replace(/\s+/g, ' ').trim()
385 return t.length > max ? t.slice(0, max - 1) + '…' : t
386}
387
388const isActive = (r: CrewRun): boolean => r.status === 'running' || r.status === 'idle'
389
390const elapsed = (r: CrewRun, at: number): number => (r.endedAt ?? Math.max(at, r.startedAt)) - r.startedAt
391
392const ctxOf = (r: CrewRun): number =>
393 r.contextMax ? Math.min(100, Math.round((r.contextTokens / r.contextMax) * 100)) : 0
394
395// A bar in text cells, in eighths of a cell, so a slow estimate visibly grows.
396const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
397const cellBar = (share: number, width: number): string => {
398 const eighths = Math.round(width * 8 * Math.max(0, Math.min(1, share)))
399 const full = Math.floor(eighths / 8)
400 const part = EIGHTHS[eighths % 8] ?? ''
401 return '█'.repeat(full) + part + '░'.repeat(Math.max(0, width - full - (part ? 1 : 0)))
402}
403
404// The estimate's pace: a third of the way after about a minute or a dozen
405// model requests, the two adding up; it never passes ESTIMATE_CAP.
406const ESTIMATE_SECONDS = 180
407const ESTIMATE_STEPS = 20
408const ESTIMATE_CAP = 0.9
409
410/**
411 * What an agent's bar shows. The engine reports no progress for a subagent, so
412 * a running agent's bar is an estimate from its run time and its model requests:
413 * quick at first, ever slower, short of full until it ends. The steps of its own
414 * to-do list (TodoWrite, TaskCreate/TaskUpdate), or ones it reported through
415 * savvy-progress, replace the estimate. An ended run's bar is full, in
416 * its end's colour. Context fill is only text beside it.
417 */
418type AgentBar = { share: number; color: string; label: string }
419
420const barOf = (r: CrewRun, at: number): AgentBar => {
421 const rep = r.report?.total ? r.report : undefined
422 const label = rep ? `${rep.done}/${rep.total}${rep.note ? ` · ${rep.note}` : ''}` : ''
423 if (r.status === 'done') return { share: 1, color: '#3B9C5F', label }
424 if (r.status === 'failed') return { share: 1, color: '#D0453F', label }
425 if (r.status === 'stopped') return { share: 1, color: '#9a9a96', label }
426 if (rep) return { share: rep.done / rep.total, color: colorOf(r), label }
427 const work = elapsed(r, at) / 1000 / ESTIMATE_SECONDS + r.steps / ESTIMATE_STEPS
428 return { share: ESTIMATE_CAP * (1 - Math.exp(-work)), color: colorOf(r), label }
429}
430
431const totals = (list: CrewRun[], at: number, gone: { cost: number; tokens: number; cache?: number }) => {
432 const cost = list.reduce((s, r) => s + r.costUsd, gone.cost)
433 const tokens = list.reduce((s, r) => s + r.tokens, gone.tokens)
434 const cache = list.reduce((s, r) => s + (r.cacheTokens ?? 0), gone.cache ?? 0)
435 const start = Math.min(...list.map(r => r.startedAt))
436 const end = Math.max(...list.map(r => r.endedAt ?? Math.max(at, r.startedAt)))
437 return { cost, tokens, cache, time: list.length ? end - start : 0 }
438}
439
440// Over the cap, the oldest finished runs go first; an active one never does.
441const trimRuns = (list: CrewRun[]): CrewRun[] => {
442 let over = list.length - MAX_RUNS
443 if (over <= 0) return list
444 return list.filter(r => (over > 0 && !isActive(r) ? (over--, false) : true))
445}
446
447// Runs that leave the list keep their figures in the totals, and their ids, so
448// the engine's list does not bring them back.
449async function retire($: EngineInterface, gone: CrewRun[]): Promise<void> {
450 if (!gone.length) return
451 await update($, cleared, c => ({
452 cost: c.cost + gone.reduce((sum, r) => sum + r.costUsd, 0),
453 tokens: c.tokens + gone.reduce((sum, r) => sum + r.tokens, 0),
454 cache: (c.cache ?? 0) + gone.reduce((sum, r) => sum + (r.cacheTokens ?? 0), 0),
455 ids: [...(c.ids ?? []), ...gone.flatMap(r => (r.agentId ? [r.agentId] : []))].slice(-MAX_CLEARED),
456 }))
457}
458
459// Writes the run list through the cap; what falls off is retired.
460async function storeRuns($: EngineInterface, fn: (list: CrewRun[]) => CrewRun[]): Promise<void> {
461 let gone: CrewRun[] = []
462 let kept: CrewRun[] = []
463 await update($, runs, list => {
464 const all = fn(list)
465 kept = trimRuns(all)
466 gone = all.filter(r => !kept.includes(r))
467 return kept
468 })
469 await retire($, gone)
470 await learnTypes($, kept)
471}
472
473// ---------------------------------------------------------------------------
474// Types, colours, costumes.
475
476// Every type its own colour: Claude Code's own types a fixed one, any other
477// type the next free one of TYPE_PALETTE when it is first seen, kept in
478// `typeColors` so it stays. Forks are grey.
479const TYPE_COLOR: Record<string, string> = {
480 'general-purpose': '#378ADD',
481 explore: '#1D9E75',
482 plan: '#BA7517',
483 'claude-code-guide': '#7F77DD',
484 'statusline-setup': '#2E9AA8',
485}
486const TYPE_PALETTE = ['#D85A30', '#C2477E', '#6F8F2E', '#9C6644', '#5C6BC0', '#E57399', '#3F8F6F', '#B0892E']
487const FORK_COLOR = '#888780'
488
489// `my-agent`, or `my-plugin:my-agent` when the agent ships in a plugin.
490const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
491const typeKey = (type: string): string => bareType(type).toLowerCase()
492
493const hash = (s: string): number => [...s].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7)
494
495// The colours handed out, read from the store at the start and on each new type.
496let typeColorCache: Record<string, string> = {}
497
498const colorOf = (r: Pick<CrewRun, 'type' | 'kind'>): string => {
499 if (r.kind === 'fork') return FORK_COLOR
500 const key = typeKey(r.type)
501 return TYPE_COLOR[key] ?? typeColorCache[key] ?? TYPE_PALETTE[hash(key) % TYPE_PALETTE.length] ?? FORK_COLOR
502}
503
504// A type seen for the first time takes the first colour no other type holds;
505// once all are taken, they go round again.
506async function learnTypes($: EngineInterface, list: readonly CrewRun[]): Promise<void> {
507 const fresh = [...new Set(list.filter(r => r.kind !== 'fork').map(r => typeKey(r.type)))].filter(k => !TYPE_COLOR[k] && !typeColorCache[k])
508 if (!fresh.length) return
509 await update($, typeColors, held => {
510 const out = { ...held }
511 for (const k of fresh) {
512 if (out[k]) continue
513 const taken = new Set(Object.values(out))
514 out[k] = TYPE_PALETTE.find(c => !taken.has(c)) ?? TYPE_PALETTE[Object.keys(out).length % TYPE_PALETTE.length] ?? FORK_COLOR
515 }
516 return out
517 })
518 typeColorCache = await read($, typeColors)
519}
520
521const costumeOf = (r: Pick<CrewRun, 'type' | 'kind'>): string => {
522 const key = typeKey(r.type)
523 if (key === 'explore') return 'pirate'
524 if (key === 'plan') return 'engineer'
525 if (key === 'claude-code-guide') return 'astronaut'
526 if (r.kind === 'workflow') return 'chef'
527 if (r.kind === 'teammate') return 'racer'
528 if (key === 'general-purpose') return 'detective'
529 return 'cap'
530}
531
532const typeLabel = (r: CrewRun): string => (r.kind === 'fork' ? tr().fork : bareType(r.type) || '—')
533
534const metaOf = (r: CrewRun, byId: Map<string, CrewRun>): string[] => {
535 const s = tr()
536 const meta = [r.effort ? `${modelName(r.model)} · ${r.effort}` : modelName(r.model)]
537 if (r.name) meta.push(`@${r.name}`)
538 if (r.kind === 'teammate') meta.push(s.teammate)
539 if (r.kind === 'workflow') meta.push(`${s.workflow}${r.workflowIndex ? ' #' + r.workflowIndex : ''}`)
540 else if (r.isBackground) meta.push(s.background)
541 const parent = r.parentId ? byId.get(r.parentId) : undefined
542 if (parent) meta.push(`↳ ${parent.description || typeLabel(parent)}`)
543 if (r.steps) meta.push(s.steps(r.steps))
544 if (r.status === 'idle') meta.push(s.idle)
545 if (r.status === 'failed') meta.push(s.failed)
546 if (r.status === 'stopped') meta.push(s.stopped)
547 return meta
548}
549
550// Parents before their children, each level in start order; depth for the indent.
551const ordered = (list: CrewRun[]): { run: CrewRun; depth: number }[] => {
552 const ids = new Set(list.map(r => r.agentId).filter(Boolean))
553 const children = new Map<string, CrewRun[]>()
554 const roots: CrewRun[] = []
555 for (const r of [...list].sort((a, b) => a.startedAt - b.startedAt)) {
556 if (r.parentId && ids.has(r.parentId)) {
557 const kids = children.get(r.parentId) ?? []
558 kids.push(r)
559 children.set(r.parentId, kids)
560 } else roots.push(r)
561 }
562 const out: { run: CrewRun; depth: number }[] = []
563 const walk = (r: CrewRun, depth: number) => {
564 out.push({ run: r, depth })
565 if (depth < 6 && r.agentId) for (const k of children.get(r.agentId) ?? []) walk(k, depth + 1)
566 }
567 roots.forEach(r => walk(r, 0))
568 return out
569}
570
571// The active tab keeps a finished parent whose child still runs, as context.
572const tabRuns = (list: CrewRun[], tab: CrewTab): CrewRun[] => {
573 if (tab === 'done') return list.filter(r => !isActive(r))
574 const byAgent = new Map(list.filter(r => r.agentId).map(r => [r.agentId ?? '', r]))
575 const keep = new Set<string>()
576 for (const r of list.filter(isActive)) {
577 let cur: CrewRun | undefined = r
578 for (let i = 0; cur && i < 8 && !keep.has(cur.id); i++) {
579 keep.add(cur.id)
580 cur = cur.parentId ? byAgent.get(cur.parentId) : undefined
581 }
582 }
583 return list.filter(r => keep.has(r.id))
584}
585
586// ---------------------------------------------------------------------------
587// Tool calls: one line for the log, and which files they read or wrote.
588
589type Loose = Record<string, unknown>
590const str = (v: unknown): string => (typeof v === 'string' ? v : '')
591
592const shortPath = (p: string, base: string): string => {
593 const path = posix(p)
594 if (base && inside(base, path, fold)) return path.slice(base.length).replace(/^\//, '') || path
595 if (home && inside(home, path, fold)) return '~' + path.slice(home.length)
596 return path
597}
598
599const summarize = (tool: string, input: Loose, base: string): string => {
600 const file = str(input.file_path) || str(input.notebook_path)
601 if (file) return shortPath(file, base)
602 const command = str(input.command)
603 if (command) return oneLine(command, 120)
604 if (tool === 'Grep') return `"${oneLine(str(input.pattern), 60)}"${input.path ? ' in ' + shortPath(str(input.path), base) : ''}`
605 if (tool === 'Glob') return oneLine(str(input.pattern), 80)
606 if (tool === 'Agent') return oneLine(str(input.description), 80)
607 if (tool === 'WebFetch') return oneLine(str(input.url), 100)
608 if (tool === 'WebSearch') return oneLine(str(input.query), 100)
609 // A to-do list: where it stands, not its first field.
610 if (tool === 'TodoWrite') {
611 const rep = todoReport(input)
612 return rep ? `${rep.done}/${rep.total}${rep.note ? ` · ${rep.note}` : ''}` : ''
613 }
614 if (tool === 'TaskCreate') return oneLine(str(input.subject), 80)
615 if (tool === 'TaskUpdate') return oneLine([str(input.subject) || str(input.taskId), str(input.status)].filter(Boolean).join(' → '), 80)
616 const first = Object.values(input).find(v => typeof v === 'string' && v.trim())
617 return typeof first === 'string' ? oneLine(first, 80) : ''
618}
619
620const toolName = (tool: string): string => tool.replace(/^mcp__([^_]+(?:_[^_]+)*)__/, (_, server: string) => `${server.slice(0, 12)}:`)
621
622// ---------------------------------------------------------------------------
623// Bookkeeping: runs.
624
625const statusFromList = (s: AgentInfo['status']): CrewStatus => {
626 if (s === 'completed') return 'done'
627 if (s === 'failed') return 'failed'
628 if (s === 'killed') return 'stopped'
629 if (s === 'idle' || s === 'waiting') return 'idle'
630 return 'running'
631}
632
633const isTerminal = (s: CrewStatus): boolean => s === 'done' || s === 'failed' || s === 'stopped'
634
635// The status line entry, by the `statusLine` option.
636async function refreshStatus($: EngineInterface): Promise<void> {
637 const n = (await read($, runs)).filter(r => r.status === 'running').length
638 $.ui.status(n && opts.statusLine !== false ? `🦀 ${tr().statusRunning(n)}` : undefined)
639}
640
641// Folds `$.agent.list()` in: agents the mod never saw spawn (a forked skill, one
642// started before the mod loaded) and states only the list knows (a kill, idling).
643async function reconcile($: EngineInterface): Promise<void> {
644 let list: AgentInfo[]
645 try {
646 list = await $.agent.list()
647 } catch {
648 return
649 }
650 if (!list.length) return
651 const at = await $.clock.now()
652 const next = [...(await read($, runs))]
653 const retired = new Set((await read($, cleared)).ids ?? [])
654 let changed = false
655 for (const info of list) {
656 const i = next.findIndex(r => r.agentId === info.id)
657 const status = statusFromList(info.status)
658 if (i < 0) {
659 // A cleared agent stays gone, unless it is working again.
660 if (retired.has(info.id) && isTerminal(status)) continue
661 next.push({
662 id: info.id,
663 agentId: info.id,
664 kind: info.teammateId ? 'teammate' : 'listed',
665 type: info.type,
666 description: info.description,
667 name: info.name,
668 model: '—',
669 isBackground: false,
670 parentId: info.parentId,
671 status,
672 startedAt: at,
673 endedAt: isTerminal(status) ? at : undefined,
674 contextTokens: 0,
675 contextMax: 0,
676 tokens: 0,
677 costUsd: 0,
678 steps: 0,
679 })
680 changed = true
681 continue
682 }
683 const run = next[i]
684 if (!run || run.status === status) continue
685 // The list may lag a turn.complete: it never revives a finished run (a new
686 // turn.step does that), but it does end or idle a running one.
687 if (isTerminal(run.status) && !isTerminal(status)) continue
688 next[i] = { ...run, status, endedAt: isTerminal(status) ? (run.endedAt ?? at) : undefined }
689 changed = true
690 }
691 if (!changed) return
692 await storeRuns($, () => next)
693 await refreshStatus($)
694}
695
696async function pushLog($: EngineInterface, agentId: string, entry: CrewLog): Promise<void> {
697 await update($, logs, prev => {
698 const all = { ...(prev ?? {}) }
699 all[agentId] = [...(all[agentId] ?? []), entry].slice(-MAX_LOG)
700 return all
701 })
702}
703
704// ---------------------------------------------------------------------------
705// Bookkeeping: files and git.
706
707// A toast, or the system's notification when the option asks for it.
708async function notify($: EngineInterface, text: string): Promise<void> {
709 if (opts.notifyVia === 'system') {
710 try {
711 const sent = await $.ui.notify(text, { title: 'Crew' })
712 if (sent.isSent) return
713 } catch {
714 // Fall back to a toast.
715 }
716 }
717 $.ui.toast(text)
718}
719
720async function agentName($: EngineInterface, agent: string): Promise<string> {
721 if (agent === 'main') return tr().main
722 const r = (await read($, runs)).find(x => x.agentId === agent)
723 return r ? r.description || typeLabel(r) : agent.slice(0, 8)
724}
725
726async function touch($: EngineInterface, paths: string[], kind: 'read' | 'write', agent: string): Promise<void> {
727 if (!paths.length) return
728 const at = await $.clock.now()
729 const conflicts: { path: string; agents: string[] }[] = []
730 await update($, files, prev => {
731 const list = [...(prev ?? [])]
732 for (const raw of paths) {
733 const path = posix(raw)
734 const key = keyOf(path, fold)
735 const i = list.findIndex(f => f.key === key)
736 const old: CrewFile = list[i] ?? { key, path, reads: 0, writes: 0, lastAt: at, lastKind: kind, agents: [] }
737 const history = [...(old.history ?? []), { at, agent, kind }].slice(-MAX_HISTORY)
738 // Several agents changing one file close together: flag it once. A commit
739 // settles what came before it. The writes are kept apart from the shown
740 // history, so reads in between never push the first of two writes out.
741 let conflict = old.conflict
742 const since = old.commitAt ?? -Infinity
743 // A file from before the writes were kept apart (a reload mid-session) has
744 // them in its history alone: take them from there once.
745 const prior = old.recentWrites ?? (old.history ?? []).filter(h => h.kind === 'write').map(h => ({ at: h.at, agent: h.agent }))
746 const recentWrites = [...prior, ...(kind === 'write' ? [{ at, agent }] : [])]
747 .filter(w => at - w.at < CONFLICT_MS && w.at > since)
748 .slice(-MAX_HISTORY)
749 if (kind === 'write') {
750 const writers = [...new Set(recentWrites.map(w => w.agent))]
751 if (writers.length > 1 && writers.some(w => !(old.conflict ?? []).includes(w))) {
752 conflict = writers
753 conflicts.push({ path, agents: writers })
754 }
755 }
756 const f: CrewFile = {
757 ...old,
758 reads: old.reads + (kind === 'read' ? 1 : 0),
759 writes: old.writes + (kind === 'write' ? 1 : 0),
760 lastAt: at,
761 lastKind: kind,
762 agents: old.agents.includes(agent) ? old.agents : [...old.agents, agent],
763 history,
764 conflict,
765 recentWrites: recentWrites.length ? recentWrites : undefined,
766 }
767 if (i < 0) list.push(f)
768 else list[i] = f
769 }
770 return list.length > MAX_FILES ? [...list].sort((a, b) => b.lastAt - a.lastAt).slice(0, MAX_FILES) : list
771 })
772 await update($, now, () => at)
773 await autoOpenPane($, 'files')
774 if (opts.warnConflicts !== false) {
775 for (const c of conflicts) {
776 const names = await Promise.all(c.agents.map(a => agentName($, a)))
777 await notify($, tr().conflict(c.path.split('/').pop() ?? c.path, names))
778 }
779 }
780}
781
782// The open file's diff against HEAD, or its text when git does not track it yet.
783async function loadDiff($: EngineInterface, key: string): Promise<void> {
784 const g = await read($, git)
785 const f = (await read($, files)).find(x => x.key === key)
786 const gf = g?.files[key]
787 const path = gf?.path ?? f?.path
788 if (!path) return
789 let text = ''
790 let format: 'diff' | 'source' = 'diff'
791 if (g && gf && !gf.code.includes('?')) {
792 const rel = path.slice(g.root.length).replace(/^\//, '')
793 text = (await runGit($, g.root, g.head ? ['diff', 'HEAD', '--no-color', '--', rel] : ['diff', '--no-color', '--', rel])) ?? ''
794 } else if (gf?.code.includes('?')) {
795 // An untracked file shows as source: read only one of a sensible size, and
796 // never a binary one, as this runs on every sync while the card is open.
797 try {
798 const st = await $.fs.stat(path)
799 const body = st.kind === 'file' && st.size <= MAX_SOURCE_BYTES ? await $.fs.read(path) : ''
800 text = body.includes('\u0000') ? '' : body
801 format = 'source'
802 } catch {
803 text = ''
804 }
805 }
806 // The hunks alone: git's header (diff --git, index, ---/+++) says nothing the card does not.
807 if (format === 'diff') {
808 const hunk = text.indexOf('\n@@')
809 if (hunk >= 0) text = text.slice(hunk + 1)
810 }
811 const next = text ? { key, text: text.slice(0, MAX_DIFF), format } : null
812 // Another file opened while git answered: this diff is no longer the card's.
813 if ((await readPanel($)).openFile !== key) return
814 if (JSON.stringify(await read($, diff)) !== JSON.stringify(next)) await update($, diff, () => next)
815}
816
817const runGit = async ($: EngineInterface, cwd: string, args: string[]): Promise<string | null> => {
818 try {
819 const r = await $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], {
820 timeoutMs: 8_000,
821 env: { GIT_OPTIONAL_LOCKS: '0' },
822 })
823 return r.exitCode === 0 ? r.stdout : null
824 } catch {
825 return null
826 }
827}
828
829// Status, line counts and new commits of the repository around the session's cwd.
830async function syncGit($: EngineInterface): Promise<void> {
831 if (gitBusy) {
832 gitQueued = true
833 return
834 }
835 gitBusy = true
836 try {
837 const cwd = posix(await $.session.cwd())
838 // The way up from the cwd, not git's absolute top level: under a packaged
839 // Windows app git sees through the folder redirection (AppData\Local\Packages\…)
840 // while the tools keep the path the session gave, and the two must match.
841 const up = await runGit($, cwd, ['rev-parse', '--show-cdup'])
842 if (up === null) {
843 if ((await read($, git)) !== null) await update($, git, () => null)
844 if ((await readPanel($)).fileFilter === 'all') await syncTree($)
845 return
846 }
847 const root = up.trim() ? resolve(cwd, up.trim(), home) : cwd
848 const headOut = await runGit($, root, ['rev-parse', '--verify', '-q', 'HEAD'])
849 const statusOut = await runGit($, root, ['status', '--porcelain=v1', '-b', '-z', '--untracked-files=all'])
850 // A git call that failed (a timeout, a lock) says nothing: keep what was seen
851 // rather than take an empty HEAD for a new repository and book it all as committed.
852 // Only a status that says so makes an empty HEAD a repository with no commits.
853 if (statusOut === null || (headOut === null && !/^## No commits yet/.test(statusOut))) return
854 const head = (headOut ?? '').trim()
855 const status = parseStatus(statusOut, root)
856 const numstat = head
857 ? await runGit($, root, ['diff', 'HEAD', '--numstat', '-z'])
858 : await runGit($, root, ['diff', '--numstat', '-z'])
859 applyNumstat(numstat ?? '', root, status.files)
860
861 const byKey: CrewGit['files'] = {}
862 for (const [path, f] of Object.entries(status.files)) byKey[keyOf(path, fold)] = f
863
864 // Commits since the last look: their files turn green.
865 const prev = await read($, git)
866 // The HEAD kept for the next look: a commit whose files git could not list
867 // this time stays new, and is booked on the next look instead of never.
868 let seenHead = head
869 // Only a HEAD that grew from the last one was committed to: a checkout, reset or
870 // rebase moves it elsewhere, and its files are not this session's commits.
871 const isCommit =
872 Boolean(prev && prev.root === root && head && prev.head !== head) &&
873 (!prev?.head || (await runGit($, root, ['merge-base', '--is-ancestor', prev.head, head])) !== null)
874 if (prev && isCommit) {
875 // After the repository's first commit, everything in its tree is new.
876 const names = prev.head
877 ? await runGit($, root, ['diff', '--name-only', '-z', prev.head, head])
878 : await runGit($, root, ['ls-tree', '-r', '--name-only', '-z', head])
879 if (names === null) seenHead = prev.head
880 const committed = (names ?? '').split('\0').filter(Boolean).map(rel => `${root}/${rel}`)
881 if (committed.length) {
882 const at = await $.clock.now()
883 const sha = head.slice(0, 7)
884 await update($, files, list => {
885 // Every file the tree knows takes the commit, however large it is (its
886 // conflict and writes settle); new ones join up to the tree's bound.
887 const out = [...(list ?? [])]
888 const index = new Map(out.map((f, i) => [f.key, i]))
889 let room = Math.max(0, MAX_FILES - out.length)
890 for (const path of committed) {
891 const key = keyOf(path, fold)
892 const i = index.get(key) ?? -1
893 if (i < 0 && room-- <= 0) continue
894 const old: CrewFile = out[i] ?? { key, path, reads: 0, writes: 0, lastAt: at, lastKind: 'commit', agents: [] }
895 const history = [...(old.history ?? []), { at, agent: 'git', kind: 'commit' as const }].slice(-MAX_HISTORY)
896 const f: CrewFile = { ...old, lastAt: at, lastKind: 'commit', commit: sha, commitAt: at, history, conflict: undefined, recentWrites: undefined }
897 if (i < 0) index.set(key, out.push(f) - 1)
898 else out[i] = f
899 }
900 // The same bound as a read or write: the newest files stay.
901 return out.length > MAX_FILES ? [...out].sort((a, b) => b.lastAt - a.lastAt).slice(0, MAX_FILES) : out
902 })
903 await update($, now, () => at)
904 }
905 }
906 const next: CrewGit = { root, head: seenHead, ...status, files: byKey }
907 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, git, () => next)
908 // The open file's diff follows the tree, also when an edit leaves its
909 // status and line counts as they were.
910 const p = await readPanel($)
911 if (p.openFile) await loadDiff($, p.openFile)
912 // The "All" filter's list follows too: new and deleted files.
913 if (p.fileFilter === 'all') await syncTree($)
914 } finally {
915 gitBusy = false
916 if (gitQueued) {
917 gitQueued = false
918 $.clock.after(300, () => void syncGit($).catch(() => undefined))
919 }
920 }
921}
922
923// Every file under the root, for the tree's "All" filter: git's own list where
924// there is a repository (tracked and untracked, what it ignores left out), else a
925// walk that skips hidden folders and the usual build and dependency ones.
926const MAX_TREE = 4000
927const MAX_WALK_DEPTH = 6
928/** Folders listed in one walk at most, however wide each level grows. */
929const MAX_WALK_DIRS = 300
930let treeBusy = false
931let treeQueued = false
932const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'target', 'vendor', '__pycache__', 'venv'])
933
934async function syncTree($: EngineInterface): Promise<void> {
935 // One walk at a time; a refresh that comes while one runs is kept for one
936 // more walk after it, so what changed meanwhile is not lost.
937 if (treeBusy) {
938 treeQueued = true
939 return
940 }
941 treeBusy = true
942 try {
943 do {
944 treeQueued = false
945 await walkTree($)
946 } while (treeQueued)
947 } finally {
948 treeBusy = false
949 }
950}
951
952async function walkTree($: EngineInterface): Promise<void> {
953 const g = await read($, git)
954 const root = g?.root ?? posix(await $.session.cwd())
955 let paths: string[] = []
956 if (g) {
957 const out = await runGit($, root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])
958 paths = (out ?? '').split('\0').filter(Boolean).slice(0, MAX_TREE).map(rel => `${root}/${rel}`)
959 } else {
960 let level = [root]
961 let listed = 0
962 for (let depth = 0; depth < MAX_WALK_DEPTH && level.length && paths.length < MAX_TREE && listed < MAX_WALK_DIRS; depth++) {
963 const next: string[] = []
964 for (const dir of level) {
965 if (listed++ >= MAX_WALK_DIRS || paths.length >= MAX_TREE) break
966 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
967 try {
968 entries = await $.fs.list(dir)
969 } catch {
970 continue
971 }
972 for (const e of entries) {
973 if (e.kind === 'dir' && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) {
974 // The queue need never hold more than the walk can still list.
975 if (next.length < MAX_WALK_DIRS) next.push(`${dir}/${e.name}`)
976 }
977 else if (e.kind === 'file' && paths.length < MAX_TREE) paths.push(`${dir}/${e.name}`)
978 }
979 }
980 level = next
981 }
982 }
983 const next = { root, paths }
984 if (JSON.stringify(await read($, tree)) !== JSON.stringify(next)) await update($, tree, () => next)
985}
986
987// Writes and commands settle a moment before git sees them.
988const queueGit = ($: EngineInterface) => {
989 $.clock.after(500, () => void syncGit($).catch(() => undefined))
990}
991
992// savvy-progress's tool through which a worker reports its own steps. The mod
993// only listens: a tool of its own would cost every request tokens.
994const STEP_TOOL = 'mcp__savvy-progress__step'
995
996// A report merges into the last one as savvy-progress merges it: a count left
997// out stays, the note is the current step's and goes with it.
998const stepReport =
999 (input: Loose) =>
1000 (prev: CrewRun['report']): CrewRun['report'] => {
1001 const num = (x: unknown, keep: number) => (typeof x === 'number' && Number.isFinite(x) ? Math.max(0, Math.round(x)) : keep)
1002 const total = num(input.total, prev?.total ?? 0)
1003 const done = num(input.done, prev?.done ?? 0)
1004 const note = typeof input.note === 'string' ? oneLine(input.note, 80) || undefined : undefined
1005 return { done: total ? Math.min(total, done) : done, total, ...(note ? { note } : {}) }
1006 }
1007
1008// An agent's progress from its list, or none when the list is empty: then a
1009// subagent's bar goes back to the estimate, and the main chat's row to its name.
1010async function setReport($: EngineInterface, holder: string, report: CrewReport | undefined): Promise<void> {
1011 if (holder === 'main') {
1012 await update($, mainReport, () => report ?? null)
1013 const at = await $.clock.now()
1014 await update($, now, () => at)
1015 return
1016 }
1017 await update($, runs, list =>
1018 list.map(r => {
1019 if (r.agentId !== holder) return r
1020 const { report: _gone, ...rest } = r
1021 return report ? { ...rest, report } : rest
1022 }),
1023 )
1024}
1025
1026// The to-do list an agent keeps, read from its calls: TodoWrite writes that
1027// agent's whole list. TaskCreate and TaskUpdate change one task of the session's
1028// shared list, which any agent may update and an update may give to another
1029// (`owner`); a task counts for its owner, else for its maker. A subagent's bar
1030// shows its share; the main chat's shows in the timeline.
1031async function trackTodos($: EngineInterface, tool: string, input: Loose, out: Loose | undefined, agent: string): Promise<void> {
1032 if (tool === 'TodoWrite') {
1033 if (Array.isArray(input.todos)) await setReport($, agent, todoReport(input))
1034 return
1035 }
1036 const list = await read($, runs)
1037 // An owner as the engine names it: an agent's id or its name.
1038 const resolve = (owner: string): string => list.find(r => r.agentId === owner || (r.name && r.name === owner))?.agentId ?? owner
1039 const id = taskIdOf(tool, input, out)
1040 let before: CrewTask[] = []
1041 let after: CrewTask[] = []
1042 await update($, tasks, all => {
1043 before = all
1044 after = applyTask(all, tool, input, out, agent)
1045 return after
1046 })
1047 // Who held the task before and who holds it now: each one's share is counted anew.
1048 const holders = new Set([...before, ...after].filter(t => t.id === id).map(t => holderOf(t, resolve)))
1049 for (const holder of holders) await setReport($, holder, tasksReport(after, holder, resolve))
1050}
1051
1052async function record($: EngineInterface, tool: string, input: Loose, out: Loose | undefined, isOk: boolean, agentId: string | undefined): Promise<void> {
1053 const agent = agentId ?? 'main'
1054 const g = await read($, git)
1055 const base = g?.root ?? posix(await $.session.cwd())
1056
1057 if (agentId && (await read($, runs)).some(r => r.agentId === agentId)) {
1058 const text = summarize(tool, input, base)
1059 const at = await $.clock.now()
1060 await pushLog($, agentId, { at, kind: 'tool', tool: toolName(tool), text, isError: !isOk || undefined })
1061 const line = `${toolName(tool)}${text ? ' ' + text : ''}`
1062 // A progress report is no action of its own: it fills the bar instead, as a
1063 // to-do list's update does (trackTodos).
1064 const report = tool === STEP_TOOL && isOk ? stepReport(input) : undefined
1065 const isProgress = tool === STEP_TOOL || isTodoTool(tool)
1066 await update($, runs, list =>
1067 list.map(r => (r.agentId !== agentId ? r : report ? { ...r, report: report(r.report) } : isProgress ? r : { ...r, lastAction: oneLine(line, 140) })),
1068 )
1069 }
1070 if (!isOk) return
1071 if (isTodoTool(tool)) await trackTodos($, tool, input, out, agent)
1072
1073 const file = str(input.file_path) || str(input.notebook_path)
1074 if (tool === 'Read' && file) await touch($, [file], 'read', agent)
1075 else if ((tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') && file) {
1076 await touch($, [file], 'write', agent)
1077 queueGit($)
1078 } else if (tool === 'Grep' && out && Array.isArray(out.filenames)) {
1079 const cwd = posix(await $.session.cwd())
1080 await touch($, (out.filenames as unknown[]).filter(f => typeof f === 'string').slice(0, 20).map(f => resolve(cwd, f as string, home)), 'read', agent)
1081 } else if (tool === 'Bash' || tool === 'PowerShell') {
1082 const command = str(input.command)
1083 const cwd = posix(await $.session.cwd())
1084 await touch($, readTargets(command, cwd, str(out?.stdout), home), 'read', agent)
1085 if (mayChange(command)) queueGit($)
1086 }
1087}
1088
1089// ---------------------------------------------------------------------------
1090// Pane helpers.
1091
1092async function storeUsage(
1093 $: EngineInterface,
1094 context: { percent?: number; tokens?: number; window: number },
1095 limits: readonly { kind: string; percentUsed: number; resetsAt?: string }[],
1096): Promise<void> {
1097 const next: CrewUsage = {
1098 context: { percent: context.percent ?? null, tokens: context.tokens, window: context.window },
1099 limits: limits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
1100 }
1101 if (JSON.stringify(await read($, usage)) !== JSON.stringify(next)) await update($, usage, () => next)
1102}
1103
1104// The pane opens by itself once a session: on the first subagent (`autoOpen`),
1105// or on the first file read or changed (`autoOpenFiles`), whichever comes first.
1106async function autoOpenPane($: EngineInterface, cause: 'agents' | 'files'): Promise<void> {
1107 if ((cause === 'agents' ? opts.autoOpen : opts.autoOpenFiles) === false) return
1108 if ((await readPanel($)).hasAutoOpened) return
1109 await setPanel($, () => ({ hasAutoOpened: true }))
1110 if ((await $.ui.panes()).some(p => p.id === PANE)) return
1111 void $.ui.open({ id: PANE, title: tr().pane }).catch(() => undefined)
1112 void syncGit($).catch(() => undefined)
1113}
1114
1115// --- updates: the published plugin.json on GitHub against this copy's own, at
1116// most once a day; a notice once per new version; installed from the settings.
1117
1118const PLUGIN_ID = 'agent-crew@agent-crew'
1119const CHECK_EVERY_MS = 24 * 3_600_000
1120/** After a failed look (offline, an error, a page that is not a manifest), the next one comes sooner. */
1121const RETRY_AFTER_MS = 30 * 60_000
1122
1123async function manifest($: EngineInterface): Promise<{ version: string; repository: string }> {
1124 try {
1125 const m = JSON.parse(String(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))) as { version?: unknown; repository?: unknown }
1126 return { version: typeof m.version === 'string' ? m.version : '0.0.0', repository: typeof m.repository === 'string' ? m.repository : '' }
1127 } catch {
1128 return { version: '0.0.0', repository: '' }
1129 }
1130}
1131
1132// `force`: look now, whatever the day; `isOffline`: only note this copy's version.
1133// One look at a time: a second call (the button again, the hourly tick) waits for
1134// the one under way instead of racing it with an older snapshot of the store.
1135// A forced look that meets a lesser one under way (offline, or not yet due) does
1136// not just wait for it: it runs after it.
1137let checkFlight: Promise<void> | undefined
1138let isFlightForced = false
1139function checkUpdate($: EngineInterface, force = false, isOffline = false): Promise<void> {
1140 const isForced = force && !isOffline
1141 if (checkFlight && (!isForced || isFlightForced)) return checkFlight
1142 const before = checkFlight ?? Promise.resolve()
1143 isFlightForced = isForced
1144 const flight: Promise<void> = before
1145 .catch(() => undefined)
1146 .then(() => lookForUpdate($, force, isOffline))
1147 .finally(() => {
1148 if (checkFlight === flight) {
1149 checkFlight = undefined
1150 isFlightForced = false
1151 }
1152 })
1153 checkFlight = flight
1154 return flight
1155}
1156
1157async function lookForUpdate($: EngineInterface, force: boolean, isOffline: boolean): Promise<void> {
1158 const own = await manifest($)
1159 const root = posix($.plugin.root)
1160 // An installed copy lives in the plugin cache; anything else is a folder of its own.
1161 const source = /\/\.claude\/plugins\/cache\//i.test(root) ? 'marketplace' : 'folder'
1162 const prev = await read($, updateInfo)
1163 const base: CrewUpdate = { current: own.version, source, root, latest: prev?.latest }
1164 const saved = (await $.store.get('update')) as { checkedAt?: number; failedAt?: number; latest?: string; notified?: string } | undefined
1165 const at = await $.clock.now()
1166 let latest = saved?.latest
1167 const isDue = (!saved?.checkedAt || at - saved.checkedAt > CHECK_EVERY_MS) && (!saved?.failedAt || at - saved.failedAt > RETRY_AFTER_MS)
1168 if (!isOffline && (force || isDue)) {
1169 await update($, updateInfo, (): CrewUpdate => ({ ...base, phase: 'checking' }))
1170 const url = rawGithubUrl(own.repository, 'plugins/agent-crew/.claude-plugin/plugin.json')
1171 let found: string | undefined
1172 try {
1173 const r = url ? await $.http.fetch(url) : undefined
1174 const v = r?.ok ? (JSON.parse(r.text) as { version?: unknown }).version : undefined
1175 if (typeof v === 'string' && /^\d+\.\d+/.test(v)) found = v
1176 } catch {
1177 // Offline, refused or not a manifest: keep what was known.
1178 }
1179 // Only a valid answer counts as the day's look; a failure is tried again within the hour.
1180 if (found) {
1181 latest = found
1182 await $.store.set('update', { ...((await $.store.get('update')) as object), checkedAt: at, failedAt: undefined, latest })
1183 } else await $.store.set('update', { ...((await $.store.get('update')) as object), failedAt: at })
1184 }
1185 await update($, updateInfo, () => ({ ...base, latest }))
1186 if (opts.checkUpdates !== false && latest && isNewer(latest, own.version) && saved?.notified !== latest) {
1187 await $.store.set('update', { ...((await $.store.get('update')) as object), notified: latest })
1188 await notify($, tr().updateAvailable(latest))
1189 }
1190}
1191
1192// `claude plugin update` after the marketplace is fetched again, then a reload.
1193let isUpdating = false
1194async function runUpdate($: EngineInterface): Promise<void> {
1195 const cur = await read($, updateInfo)
1196 if (!cur || cur.source !== 'marketplace' || isUpdating) return
1197 isUpdating = true
1198 try {
1199 await installUpdate($, cur)
1200 } finally {hooks/crab.ts 157 lines1// SVG drawing helpers: text fitting, the shared stylesheet, and the pixel crabs.
2// The crabs and their costumes follow savvy-progress (johnnyvizz, MIT).
3
4export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
5
6export const xml = (s: string): string =>
7 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
8
9// Rough advance of system UI text, in em; good enough to size a slot.
10const charEm = (ch: string): number =>
11 /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZÄÖÜmw@%]/.test(ch) ? 0.72 : 0.56
12
13export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
14
15export const fitText = (s: string, size: number, maxW: number): string => {
16 if (maxW <= 0) return ''
17 if (textWidth(s, size) <= maxW) return s
18 let out = ''
19 for (const ch of s) {
20 if (textWidth(out + ch + '…', size) > maxW) break
21 out += ch
22 }
23 return out + '…'
24}
25
26const PANE_CSS = `<style>
27.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}.tree{stroke:#d6d5d1}
28@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}.tree{stroke:#454543}}
29</style>`
30
31// Pixel Clawd: a 24×18 crab on a 30×28 grid, one costume per type of agent.
32export const CLAY = '#D97757'
33const INK = '#1F1E1D'
34
35type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
36
37const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
38 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
39
40const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
41 f(7, 10, 16, 12, CLAY)
42 f(3, 14, 4, 4, CLAY)
43 f(23, 14 + armFront, 4, 4, CLAY, armCls)
44 f(9, 12, 2, 2, INK)
45 f(19, 12, 2, 2, INK)
46 f(7, 22, 2, 4, CLAY, 'la')
47 f(17, 22, 2, 4, CLAY, 'la')
48 f(11, 22, 2, 4, CLAY, 'lb')
49 f(21, 22, 2, 4, CLAY, 'lb')
50}
51
52// Pure CSS. Each delay counts back from `--t`, the time a drawing stands for:
53// the desktop rebuilds a picture on every redraw of the pane, which would cut a
54// running animation off, so a crab drawn with `t` is a still of its walk at that
55// moment (`.flip` pauses it there), and the pane redraws a few times a second
56// while one walks: a flip book. Periods divide one second, and none is shorter
57// than the half second the redraws sample twice.
58const CRAB_CSS = `<style>
59.run .la{animation:st .5s steps(1) infinite calc(0s - var(--t,0s))}.run .lb{animation:st .5s steps(1) infinite calc(-.25s - var(--t,0s))}
60.run .bd{animation:bob .5s steps(1) infinite calc(-.125s - var(--t,0s))}
61.run g{transform-box:fill-box}
62@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
63.c-astronaut.run{animation:float 1s ease-in-out infinite calc(0s - var(--t,0s))}
64.c-astronaut.run .la,.c-astronaut.run .lb,.c-astronaut.run .bd{animation:none}
65.c-astronaut.run .ant{animation:blink 1s steps(1) infinite calc(0s - var(--t,0s))}
66.c-astronaut.run .star{animation:blink .5s steps(1) infinite calc(-.25s - var(--t,0s))}
67@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
68.c-detective.run .it{animation:scan 1s steps(1) infinite calc(0s - var(--t,0s))}
69.c-detective.run .gl{animation:blink 1s steps(1) infinite calc(-.5s - var(--t,0s))}
70@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
71.c-engineer.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite calc(0s - var(--t,0s))}
72@keyframes twist{50%{transform:rotate(-35deg)}}
73.c-chef.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite calc(0s - var(--t,0s))}
74.c-chef.run .egg{animation:flip 1s ease-in-out infinite calc(0s - var(--t,0s))}
75@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
76.c-racer.run .flag{transform-origin:0 50%;animation:wave .5s steps(1) infinite calc(0s - var(--t,0s))}
77@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
78.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite calc(0s - var(--t,0s))}
79@keyframes fence{50%{transform:rotate(25deg)}}
80.flip,.flip *{animation-play-state:paused!important}
81@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
82</style>`
83
84const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
85 // Astronaut in a glass dome (claude-code-guide).
86 astronaut: (f, t) => {
87 crabBody(f)
88 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
89 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
90 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
91 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
92 },
93 // Detective with a magnifier (general-purpose).
94 detective: (f, t) => {
95 crabBody(f, -4, 'it')
96 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
97 f(6, 9, 18, 1, t)
98 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
99 f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
100 },
101 // Engineer in a hard hat (Plan).
102 engineer: (f, t) => {
103 crabBody(f)
104 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
105 f(13, 5, 4, 2, t)
106 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
107 },
108 // Chef tossing an omelette (workflow agents).
109 chef: (f, t) => {
110 crabBody(f, -4, 'pan')
111 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
112 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
113 },
114 // Racer with a checkered flag (teammates).
115 racer: (f, t) => {
116 crabBody(f, -4)
117 stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
118 f(25, 1, 1, 9, '#8E929A')
119 stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
120 },
121 // Pirate scouting the code (Explore).
122 pirate: f => {
123 crabBody(f)
124 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
125 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
126 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
127 },
128 // Everyone else wears a cap in their type's colour.
129 cap: (f, t) => {
130 crabBody(f)
131 stamp(f, 8, 6, ['...cccccccc...', '.cccccccccccc.', 'cccccccccccccvvvv'], { c: t, v: t })
132 },
133}
134
135/** One step of the flip book: the pane redraws a walking crab this often. */
136export const FLIP_MS = 250
137
138// `t`, the clock's time in ms: a still of the walk at that moment (CRAB_CSS).
139export const crab = (x: number, y: number, costume: string, color: string, dim = false, isWalking = false, scale = 1.1, t?: number): string => {
140 const groups = new Map<string, string[]>([['bd', []]])
141 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
142 if (!groups.has(cls)) groups.set(cls, [])
143 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
144 }
145 ;(COSTUMES[costume] ?? COSTUMES.cap ??((g: Fill) => crabBody(g)))(f, color)
146 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
147 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
148 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
149 const isStill = isWalking && t !== undefined
150 const classes = [`c-${costume}`, ...(isWalking ? ['run'] : []), ...(isStill ? ['flip'] : [])].join(' ')
151 const style = isStill ? ` style="--t:${((t % 1000) / 1000).toFixed(3)}s"` : ''
152 return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="${classes}"${style}>${body}${group('la')}${group('lb')}</g></g>`
153}
154
155export const svg = (W: number, H: number, body: string, withCrabs = true): string =>
156 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${withCrabs ? CRAB_CSS : ''}${body}</svg>`
157hooks/filerows.tsx 89 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3// The file tree's rows on the cell grid, after filetree's hooks/rows.tsx (Kurt
4// Buhler, MIT): one row a line, the hovered one lit, a fresh name shimmering
5// letter by letter. A click anywhere on a row posts `{ press: id }` to the hooks
6// module, so a row takes presses while it shimmers.
7
8export type Seg = { t: string; c?: string; b?: boolean; dim?: boolean; sh?: string }
9// `isOpen` on a folder: whether it is drawn open, so a press shuts what shows open.
10export type FileRowSpec = { id: string; isOpen?: boolean; left: Seg[]; right: Seg[] }
11export type FileRowsProps = { rows: FileRowSpec[]; tones: Record<string, string[]>; hoverBg: string }
12type Local = { hover: number; phase: number; ref: { stop?: () => void } }
13
14const TICK_MS = 90
15
16// A bright band runs along the name: the crest, its shoulders, then the base.
17function shimmer(i: number, phase: number, len: number, palette: string[]): string {
18 const band = ((phase * 1.6) % (len + 8)) - 4
19 const d = Math.abs(i - band)
20 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
21}
22
23const FileRows: ClientModule<FileRowsProps, Local> = (props, surface) => {
24 const { Box, Text } = surface.elements
25 let state = surface.state
26 if (state === undefined) {
27 state = { hover: -1, phase: 0, ref: {} }
28 surface.setState(state)
29 }
30 const lit = props.rows.some(r => r.left.some(s => s.sh))
31 if (lit && !state.ref.stop) {
32 state.ref.stop = surface.every(TICK_MS, () => {
33 const cur = surface.state
34 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
35 })
36 } else if (!lit && state.ref.stop) {
37 state.ref.stop()
38 state.ref.stop = undefined
39 }
40
41 surface.onPointer(e => {
42 const cur = surface.state ?? state
43 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
44 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
45 return
46 }
47 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
48 const row = props.rows[e.y]
49 if (e.type === 'down' && (e.button ?? 'left') === 'left' && row) surface.post(row.isOpen === undefined ? { press: row.id } : { press: row.id, isOpen: row.isOpen })
50 })
51
52 const draw = (s: Seg, k: string): RenderElement => {
53 const palette = s.sh ? props.tones[s.sh] : undefined
54 if (!palette) {
55 return (
56 <Text key={k} color={s.c} bold={s.b} dimColor={s.dim}>
57 {s.t}
58 </Text>
59 )
60 }
61 const chars = [...s.t]
62 return (
63 <Text key={k} bold={s.b}>
64 {chars.map((ch, i) => (
65 <Text key={`${k}-${i}`} color={shimmer(i, state.phase, chars.length, palette)}>
66 {ch}
67 </Text>
68 ))}
69 </Text>
70 )
71 }
72
73 return (
74 <Box flexDirection="column">
75 {props.rows.map((r, i) => (
76 <Box key={r.id} flexDirection="row" height={1} overflow="hidden" backgroundColor={i === state.hover ? props.hoverBg : undefined}>
77 <Box flexShrink={1} overflow="hidden">
78 <Text wrap="truncate-end">{r.left.map((s, j) => draw(s, `l${j}`))}</Text>
79 </Box>
80 <Box flexGrow={1} />
81 <Text>{r.right.map((s, j) => draw(s, `r${j}`))}</Text>
82 </Box>
83 ))}
84 </Box>
85 )
86}
87
88export default FileRows
89hooks/searchbox.tsx 79 lines1import type { ClientModule } from 'claude-code'
2
3// The file tree's search field, drawn on the cell grid so it spans the frame and
4// carries no submit button: a click gives it the keys and posts `{ focus }`,
5// every edit `{ query }`, Enter `{ submit }`, the × at the right `{ query: '' }`.
6//
7// A region never hears that it lost the keys, so whether it looks focused is the
8// hooks module's to say (`isFocused`): it clears that when the person types in
9// the prompt or presses anything else in the pane.
10
11export type SearchBoxProps = { value: string; placeholder: string; hits: string; line: string; accent: string; isFocused: boolean }
12// `posted`: the values sent and not yet seen back. Keys typed faster than a round
13// trip make the plugin's props trail the field; an older one coming back is an
14// echo, not a change, and must not wind the text back and lose a key.
15type Local = { text: string; synced: string; posted: string[] }
16
17const SearchBox: ClientModule<SearchBoxProps, Local> = (props, surface) => {
18 const { Box, Text } = surface.elements
19 let state = surface.state
20 if (state === undefined) {
21 state = { text: props.value, synced: props.value, posted: [] }
22 surface.setState(state)
23 } else if (props.value !== state.synced) {
24 const at = state.posted.indexOf(props.value)
25 if (at >= 0) {
26 // An echo of something typed: drop it and what came before, keep the text.
27 state = { ...state, synced: props.value, posted: state.posted.slice(at + 1) }
28 } else {
29 // The plugin changed the query (the ×, a cleared filter): take it.
30 state = { ...state, text: props.value, synced: props.value, posted: [] }
31 }
32 surface.setState(state)
33 }
34
35 const edit = (text: string) => {
36 const cur = surface.state ?? state
37 surface.setState({ ...cur, text, posted: [...cur.posted, text].slice(-20) })
38 // One post a frame reaches the hooks: a key proves the focus, so it rides along.
39 surface.post({ query: text, focus: true })
40 }
41
42 surface.onPointer(e => {
43 if (e.type !== 'down' || (e.button ?? 'left') !== 'left') return
44 const cur = surface.state ?? state
45 if (cur.text && e.x >= surface.columns - 4) edit('')
46 else if (!props.isFocused) surface.post({ focus: true })
47 })
48
49 surface.onKey(e => {
50 const cur = surface.state ?? state
51 if (e.key === 'return') surface.post({ submit: cur.text, focus: true })
52 else if (e.key === 'backspace') edit([...cur.text].slice(0, -1).join(''))
53 else if (e.key === 'space') edit(cur.text + ' ')
54 else if ([...e.key].length === 1 && !e.ctrl && !e.meta) edit(cur.text + e.key)
55 })
56
57 const tail = state.text ? `${props.hits} ×` : ''
58 return (
59 <Box borderStyle="round" borderColor={props.isFocused ? props.accent : props.line} paddingX={1} flexDirection="row">
60 <Box flexGrow={1} flexShrink={1} overflow="hidden">
61 {state.text ? (
62 <Text wrap="truncate-start">
63 {state.text}
64 {props.isFocused ? <Text color={props.accent}>▏</Text> : null}
65 </Text>
66 ) : (
67 <Text dimColor wrap="truncate-end">
68 {props.isFocused ? <Text color={props.accent}>▏</Text> : null}
69 {props.placeholder}
70 </Text>
71 )}
72 </Box>
73 {tail ? <Text dimColor>{tail}</Text> : null}
74 </Box>
75 )
76}
77
78export default SearchBox
79hooks/files.ts 489 lines1// Paths, shell-command reading and git output parsing for the files section.
2// The command splitting and reader detection follow filetree's hooks/git.ts
3// (Kurt Buhler, MIT): https://github.com/data-goblin/claude-code-filetree
4
5import type { CrewGit, CrewGitFile } from '../types'
6
7// --- paths: everything is kept with forward slashes and an upper-case drive.
8
9// Git Bash spells `C:\` as `/c/`; read back only where there are drives, since
10// elsewhere `/a/project` is just a folder.
11let hasDrives = false
12export const useDrives = (on: boolean): void => {
13 hasDrives = on
14}
15
16export const posix = (p: string): string =>
17 (hasDrives ? p.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`) : p.replace(/\\/g, '/'))
18 .replace(/^([a-z]):/, (_, d: string) => `${d.toUpperCase()}:`)
19 .replace(/(.)\/+$/, '$1')
20
21export const isAbsolute = (p: string): boolean => p.startsWith('/') || /^[A-Za-z]:\//.test(p)
22
23export function resolve(cwd: string, p: string, home = ''): string {
24 let path = posix(p)
25 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
26 const drive = /^[A-Za-z]:\//.test(path) ? path.slice(0, 2) : ''
27 const parts = isAbsolute(path) ? [drive] : posix(cwd).split('/')
28 for (const seg of path.slice(drive.length).split('/')) {
29 if (!seg || seg === '.') continue
30 if (seg === '..') {
31 if (parts.length > 1) parts.pop()
32 } else parts.push(seg)
33 }
34 const out = parts.join('/')
35 return out === '' ? '/' : /^[A-Za-z]:$/.test(out) ? `${out}/` : out
36}
37
38export const inside = (root: string, path: string, fold: boolean): boolean => {
39 const r = fold ? root.toLowerCase() : root
40 const p = fold ? path.toLowerCase() : path
41 return p === r || p.startsWith(r.endsWith('/') ? r : `${r}/`)
42}
43
44/** The key a path is stored under: case-folded where the file system folds case. */
45export const keyOf = (path: string, fold: boolean): string => (fold ? posix(path).toLowerCase() : posix(path))
46
47// --- shell commands.
48
49function segments(command: string): string[][] {
50 const out: string[][] = []
51 let cur: string[] = []
52 let tok = ''
53 let quote = ''
54 let has = false
55 const endTok = () => {
56 if (has) cur.push(tok)
57 tok = ''
58 has = false
59 }
60 const endSeg = () => {
61 endTok()
62 if (cur.length) out.push(cur)
63 cur = []
64 }
65 for (let i = 0; i < command.length; i++) {
66 const ch = command[i] ?? ''
67 if (quote) {
68 if (ch === quote) quote = ''
69 else tok += ch
70 } else if (ch === '"' || ch === "'") {
71 quote = ch
72 has = true
73 } else if (ch === ' ' || ch === '\t') endTok()
74 else if (ch === '\n' || ch === ';' || ch === '|' || (ch === '&' && command[i - 1] !== '>' && command[i + 1] !== '>')) {
75 if (command[i + 1] === ch) i++
76 endSeg()
77 } else {
78 tok += ch
79 has = true
80 }
81 }
82 endSeg()
83 return out
84}
85
86const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'wc', 'sed', 'awk', 'jq', 'Get-Content', 'gc', 'type'])
87const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
88const LISTS_FILES = new Set(['rg', 'grep', 'egrep', 'fgrep'])
89
90// Options that take the next word as their value, per reader: `-n` does for
91// head, but numbers lines for grep and silences sed.
92const GREP_VALUED = /^-(e|f|m|A|B|C|-regexp|-file|-max-count|-after-context|-before-context|-context)$/
93const CONTENT_VALUED = /^-(TotalCount|Head|First|Tail|Last|Encoding|Delimiter|ReadCount)$/i
94const VALUED: Record<string, RegExp> = {
95 rg: /^-(e|f|g|t|T|m|A|B|C|M|j|-regexp|-file|-glob|-iglob|-type|-type-not|-max-count|-after-context|-before-context|-context|-max-columns|-threads)$/,
96 grep: GREP_VALUED,
97 egrep: GREP_VALUED,
98 fgrep: GREP_VALUED,
99 head: /^-(n|c|-lines|-bytes)$/,
100 tail: /^-(n|c|s|-lines|-bytes|-sleep-interval|-pid)$/,
101 sed: /^-(e|f|l|-expression|-file|-line-length)$/,
102 awk: /^-(f|v|F)$/,
103 jq: /^-(f|-from-file|-indent)$/,
104 bat: /^-(l|H|r|-language|-highlight-line|-line-range|-theme|-style)$/,
105 'Get-Content': CONTENT_VALUED,
106 gc: CONTENT_VALUED,
107}
108
109const valuesOf = (head: string, option: string): number =>
110 head === 'jq' && /^--(arg|argjson|slurpfile|rawfile)$/.test(option) ? 2 : VALUED[head]?.test(option) ? 1 : 0
111
112// A pattern or script given by option leaves no positional one to skip.
113const givesScript = (head: string, option: string): boolean =>
114 head === 'jq' ? /^-(f|-from-file)$/.test(option) : /^-(e|f|-regexp|-file|-expression)$/.test(option)
115
116// Files without an extension that are files all the same.
117const NAMED_FILES = /(^|\/)(Makefile|GNUmakefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|Brewfile|LICENSE|LICENCE|COPYING|NOTICE|README|CHANGELOG|AUTHORS|CODEOWNERS)$/
118
119// A path that names a file rather than a folder, as far as its spelling tells:
120// a name with an extension, a dotfile (`.gitignore`), or a well-known name.
121const looksLikeFile = (p: string): boolean =>
122 !/[*?<>|$]/.test(p) && (/[^/]\.[A-Za-z0-9]{1,8}$/.test(p) || /(^|\/)\.[A-Za-z0-9][\w.-]*$/.test(p) || NAMED_FILES.test(p))
123
124// Words that run the command after them: `sudo cat a`, `env FOO=1 cat a`, `time cat a`.
125const WRAPPERS = new Set(['sudo', 'env', 'command', 'time', 'nohup', 'exec', 'builtin', 'nice'])
126
127/** A segment's words without its environment prefixes and wrappers: the command itself first. */
128function commandWords(raw: string[]): string[] {
129 // Assignments only at the front: `awk -v n=3` keeps its own.
130 const dropAssignments = (w: string[]) => {
131 let i = 0
132 while (i < w.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i] ?? '')) i++
133 return w.slice(i)
134 }
135 let words = dropAssignments(raw)
136 while (words.length && WRAPPERS.has(words[0]?.split('/').pop() ?? '')) {
137 const wrapper = words[0]
138 words = words.slice(1)
139 // Their own options (`sudo -u root`, `nice -n 10`), and env's assignments.
140 while (words[0]?.startsWith('-')) words = words.slice(wrapper === 'sudo' || wrapper === 'nice' ? (/^-[ugCn]$/.test(words[0]) ? 2 : 1) : 1)
141 words = dropAssignments(words)
142 }
143 return words
144}
145
146// A redirection, alone (`>`, `2>>`, `<`) or with its target attached (`2>err.log`, `<a.ts`).
147const REDIRECT = /^(\d*|&)(>>?|<)(&\d+)?$/
148const REDIRECT_ATTACHED = /^(\d*|&)(>>?|<)(?!&)(.+)$/
149
150// Options whose value is a file the command reads: a script, a pattern list.
151const FILE_VALUED: Record<string, RegExp> = {
152 awk: /^-f$/,
153 sed: /^(-f|--file)$/,
154 jq: /^(-f|--from-file)$/,
155 grep: /^(-f|--file)$/,
156 rg: /^(-f|--file)$/,
157}
158
159/** Files a shell command reads: cat/head/sed/rg arguments, and the paths rg -l / grep -l print. */
160export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
161 let cwd: string | undefined = sessionCwd
162 const out = new Set<string>()
163 let listsPaths = false
164 const add = (p: string) => {
165 // After `cd -` or `popd` the folder is unknown: only absolute paths still say where.
166 if (cwd !== undefined || isAbsolute(posix(p)) || p.startsWith('~')) out.add(resolve(cwd ?? '/', p, home))
167 }
168 for (const raw of segments(command)) {
169 const tokens = commandWords(raw)
170 const head = tokens[0]?.split('/').pop() ?? ''
171 if (head === 'cd' || head === 'Set-Location' || head === 'pushd') {
172 const to = tokens.slice(1).find(t => !t.startsWith('-') || t === '-')
173 if (to === '-' || (head === 'pushd' && !to)) cwd = undefined
174 else if (to && cwd !== undefined) cwd = resolve(cwd, to, home)
175 else if (to && (isAbsolute(posix(to)) || to.startsWith('~'))) cwd = resolve('/', to, home)
176 else if (!to && home) cwd = home
177 continue
178 }
179 if (head === 'popd') {
180 cwd = undefined
181 continue
182 }
183 if (!READERS.has(head)) continue
184 if (head === 'sed' && tokens.some(t => /^-[a-zA-Z]*i/.test(t) || t.startsWith('--in-place'))) continue
185 // -l among combined flags (`-rl`, `-rnl`) too: the output is a list of paths.
186 if (LISTS_FILES.has(head) && tokens.some(t => /^-[A-Za-z]*l[A-Za-z]*$/.test(t) || /^--(files-with-matches|files)$/.test(t))) listsPaths = true
187 const args = tokens.slice(1)
188 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => givesScript(head, t))
189 for (let i = 0; i < args.length; i++) {
190 const a = args[i] ?? ''
191 // A redirection's target is written or fed in, never an argument: `< a.ts` is read.
192 if (REDIRECT.test(a)) {
193 const target = args[i + 1]
194 if (a.endsWith('<') && target && looksLikeFile(target)) add(target)
195 i++
196 continue
197 }
198 const attached = REDIRECT_ATTACHED.exec(a)
199 if (attached) {
200 if (attached[2] === '<' && attached[3] && looksLikeFile(attached[3])) add(attached[3])
201 continue
202 }
203 if (a.startsWith('-')) {
204 const value = args[i + 1]
205 if (FILE_VALUED[head]?.test(a) && value && looksLikeFile(value)) add(value)
206 i += valuesOf(head, a)
207 continue
208 }
209 if (skipPattern) {
210 skipPattern = false
211 continue
212 }
213 if (looksLikeFile(a)) add(a)
214 }
215 }
216 if (listsPaths) {
217 for (const line of stdout.split('\n').slice(0, 200)) {
218 const path = posix(line.trim()).match(/^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
219 if (path && looksLikeFile(path)) add(path)
220 }
221 }
222 return [...out].slice(0, 40)
223}
224
225// Commands that only look: they change neither the tree nor the repository.
226const LOOKERS = /^(cd|pushd|popd|ls|echo|pwd|find|fd|tree|which|printf|true|false|test|\[|sort|uniq|diff|cmp|stat|file|du|df|date|cut|tr|nl|column|basename|dirname|realpath|readlink|whoami|uname|hostname|id|seq|yes|head|tail|wc|rev|fold|expand|comm|join|paste|md5sum|sha1sum|sha256sum|Get-ChildItem|Select-String|Get-Item|Get-Location|Test-Path|Resolve-Path|Get-Command|Select-Object|Measure-Object|Where-Object|Sort-Object|Format-Table|Format-List|Out-Null|Out-String|Write-Output|Write-Host)$/
227
228// git's read-only subcommands; branch, remote, stash and config are read-only only while they list.
229const GIT_LOOKS = /^(status|log|diff|show|blame|rev-parse|ls-files|ls-tree|grep|describe|reflog|shortlog|ls-remote|cat-file|rev-list|merge-base|show-ref|for-each-ref|name-rev|count-objects|var|version|help|whatchanged)$/
230
231/** git's subcommand and its words, past the global options (`--no-pager`, `-C dir`, `-c k=v`). */
232function gitCommand(words: string[]): [string, string[]] {
233 let i = 1
234 while (i < words.length) {
235 const w = words[i] ?? ''
236 if (w === '-C' || w === '-c' || w === '--git-dir' || w === '--work-tree' || w === '--namespace') i += 2
237 else if (w.startsWith('-')) i += 1
238 else break
239 }
240 return [words[i] ?? '', words.slice(i + 1)]
241}
242
243function gitChanges(words: string[]): boolean {
244 const [sub, rest] = gitCommand(words)
245 if (!sub) return false
246 // branch and remote read only while they list: `branch -m`, `remote update` change things.
247 if (sub === 'branch')
248 return !rest.every(t => /^(-a|-r|-v|-vv|-l|--all|--remotes|--list|--verbose|--show-current|--contains|--merged|--no-merged|--sort=.*|--format=.*|--color.*|--no-color)$/.test(t) || rest.includes('--list'))
249 if (sub === 'remote') return !(rest.length === 0 || rest.every(t => t === '-v' || t === '--verbose') || rest[0] === 'show' || rest[0] === 'get-url')
250 if (sub === 'stash') return !(rest[0] === 'list' || rest[0] === 'show')
251 if (sub === 'config') return !rest.some(t => /^(--get|--get-all|--get-regexp|--list|-l)$/.test(t))
252 if (sub === 'diff' || sub === 'log' || sub === 'show') return rest.some(t => t.startsWith('--output'))
253 return !GIT_LOOKS.test(sub)
254}
255
256/** Whether a command can change the repository or the working tree: anything but plain readers. */
257export function mayChange(command: string): boolean {
258 // Redirections into a file write; quoted text (`awk '$1 > 3'`, `echo "a > b"`) and the
259 // null devices (`2>/dev/null`, `2>$null`, `> NUL`) do not.
260 const bare = ` ${command}`
261 .replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, "''")
262 .replace(/\d*>&\d/g, ' ')
263 .replace(/(\d*|&)>>?\s*(\/dev\/null|\$null|NUL)(?![\w/.])/gi, ' ')
264 if (/[^>]>[^>&]|>>/.test(bare)) return true
265 return segments(command).some(raw => {
266 const words = commandWords(raw)
267 const head = words[0]?.split('/').pop() ?? ''
268 if (!head) return false
269 if (head === 'git') return gitChanges(words)
270 // Readers and finders that write all the same: in place, or by what they run.
271 if (head === 'sed' && words.some(t => /^-[a-zA-Z]*i/.test(t) || t.startsWith('--in-place'))) return true
272 if ((head === 'awk' || head === 'gawk') && words.includes('inplace')) return true
273 if (head === 'find' && words.some(t => /^-(delete|exec|execdir|ok|okdir|fprint|fprint0|fprintf|fls)$/.test(t))) return true
274 if (head === 'fd' && words.some(t => /^(-x|-X|--exec|--exec-batch)(=|$)/.test(t))) return true
275 if (head === 'sort' && words.some(t => /^(-o|--output)(=|$)/.test(t) || /^-[A-Za-z]*o$/.test(t))) return true
276 if (head === 'uniq' && words.slice(1).filter(t => !t.startsWith('-')).length > 1) return true
277 return !(READERS.has(head) || LOOKERS.test(head))
278 })
279}
280
281// --- git output.
282
283/** `git status --porcelain=v1 -b -z`: the branch line, then one entry per path. */
284export function parseStatus(raw: string, root: string): Pick<CrewGit, 'branch' | 'upstream' | 'ahead' | 'behind'> & { files: Record<string, CrewGitFile> } {
285 const parts = raw.split('\0')
286 const files: Record<string, CrewGitFile> = {}
287 let branch = ''
288 let upstream = ''
289 let ahead = 0
290 let behind = 0
291 for (let i = 0; i < parts.length; i++) {
292 const entry = parts[i] ?? ''
293 if (!entry) continue
294 if (entry.startsWith('## ')) {
295 // A branch may hold single dots (`release/0.3`), never `..`.
296 const m = /^## (?:No commits yet on )?(\S+?)(?:\.\.\.(\S+))?(?=\s|$)(?: \[(.*)\])?/.exec(entry)
297 branch = m?.[1] ?? ''
298 upstream = m?.[2] ?? ''
299 ahead = Number(/ahead (\d+)/.exec(m?.[3] ?? '')?.[1] ?? 0)
300 behind = Number(/behind (\d+)/.exec(m?.[3] ?? '')?.[1] ?? 0)
301 continue
302 }
303 const xy = entry.slice(0, 2)
304 const rel = entry.slice(3)
305 // A rename or copy, staged or not (`git add -N` then a move): the original path follows.
306 if (/[RC]/.test(xy)) i++
307 if (xy === '!!' || rel.endsWith('/')) continue
308 files[`${root}/${rel}`] = { path: `${root}/${rel}`, code: xy, add: 0, del: 0 }
309 }
310 return { branch, upstream, ahead, behind, files }
311}
312
313/** `git diff HEAD --numstat -z`: added and deleted lines per path. */
314export function applyNumstat(raw: string, root: string, files: Record<string, CrewGitFile>): void {
315 const parts = raw.split('\0')
316 for (let i = 0; i < parts.length; i++) {
317 const m = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(parts[i] ?? '')
318 if (!m) continue
319 let rel = m[3] ?? ''
320 if (!rel) {
321 i += 2 // a rename: old path, then new path
322 rel = parts[i] ?? ''
323 }
324 const path = `${root}/${rel}`
325 const f = files[path] ?? { path, code: ' M', add: 0, del: 0 }
326 files[path] = { ...f, add: m[1] === '-' ? 0 : Number(m[1]), del: m[2] === '-' ? 0 : Number(m[2]) }
327 }
328}
329
330// --- versions.
331
332/**
333 * Whether `a` is a later version than `b`, as semver orders them: the dotted
334 * numbers first (`0.10.0` > `0.9.2`); at a tie a release outranks its
335 * pre-releases (`1.0.0` > `1.0.0-beta.1`), which compare part by part, numbers
336 * below words; build metadata after `+` counts for nothing.
337 */
338export function isNewer(a: string, b: string): boolean {
339 return compareVersions(a, b) > 0
340}
341
342export function compareVersions(a: string, b: string): number {
343 const split = (v: string) => {
344 const [core = '', pre] = v.trim().replace(/^v/, '').split('+')[0]?.split(/-(.*)/s) ?? []
345 return { nums: core.split('.').map(n => Number.parseInt(n, 10) || 0), pre: pre ? pre.split('.') : [] }
346 }
347 const va = split(a)
348 const vb = split(b)
349 for (let i = 0; i < Math.max(va.nums.length, vb.nums.length, 3); i++) {
350 const d = (va.nums[i] ?? 0) - (vb.nums[i] ?? 0)
351 if (d) return Math.sign(d)
352 }
353 if (!va.pre.length || !vb.pre.length) return va.pre.length === vb.pre.length ? 0 : va.pre.length ? -1 : 1
354 for (let i = 0; i < Math.max(va.pre.length, vb.pre.length); i++) {
355 const x = va.pre[i]
356 const y = vb.pre[i]
357 if (x === undefined || y === undefined) return x === undefined ? -1 : 1
358 if (x === y) continue
359 const nx = /^\d+$/.test(x)
360 const ny = /^\d+$/.test(y)
361 if (nx && ny) return Math.sign(Number(x) - Number(y))
362 if (nx !== ny) return nx ? -1 : 1
363 return x < y ? -1 : 1
364 }
365 return 0
366}
367
368/** The raw URL of a file on a GitHub repository's default branch, from the manifest's `repository`. */
369export function rawGithubUrl(repository: string, path: string): string | undefined {
370 const m = /github\.com[/:]([^/]+)\/([^/#?]+?)(?:\.git)?\/?$/.exec(repository.trim())
371 return m ? `https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${path}` : undefined
372}
373
374// --- opening a file in the app the system gives its kind, after filetree's
375// hooks/open.ts. On Windows the path travels in the environment, never quoted.
376
377const WIN_OPEN =
378 "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:CREW_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
379
380export function openCommand(os: 'linux' | 'darwin' | 'win32', path: string): { argv: string[]; env?: Record<string, string> } {
381 if (os === 'win32') return { argv: ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN], env: { CREW_OPEN_TARGET: path.replace(/\//g, '\\') } }
382 if (os === 'darwin') return { argv: ['open', '--', path] }
383 return { argv: ['sh', '-c', 'setsid -f xdg-open "$1" </dev/null >/dev/null 2>&1', 'sh', path] }
384}
385
386// --- the files section as a tree.
387
388export type TreeRow = {
389 /** Folder rows carry the folded chain (`hooks/sub`); file rows their name. */
390 name: string
391 depth: number
392 isDir: boolean
393 key: string
394}
395
396type Node = { name: string; key: string; dirs: Map<string, Node>; files: { name: string; key: string }[] }
397
398/** Folders first, then files, alphabetically; a folder with one sub-folder and no files folds into it. */
399// `fold`: where the file system folds case (Windows), `Src` and `src` are one folder,
400// keyed in lower case and named as first seen.
401export function treeRows(entries: { key: string; label: string }[], fold = false): TreeRow[] {
402 const root: Node = { name: '', key: '', dirs: new Map(), files: [] }
403 for (const e of entries) {
404 const segs = e.label.split('/').filter(Boolean)
405 let node = root
406 for (const seg of segs.slice(0, -1)) {
407 const id = fold ? seg.toLowerCase() : seg
408 let next = node.dirs.get(id)
409 if (!next) {
410 next = { name: seg, key: `${node.key}/${id}`, dirs: new Map(), files: [] }
411 node.dirs.set(id, next)
412 }
413 node = next
414 }
415 node.files.push({ name: segs.at(-1) ?? e.label, key: e.key })
416 }
417 const rows: TreeRow[] = []
418 const walk = (node: Node, depth: number) => {
419 for (const dir of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
420 let d = dir
421 let name = d.name
422 while (d.files.length === 0 && d.dirs.size === 1) {
423 const only = [...d.dirs.values()][0]
424 if (!only) break
425 d = only
426 name += `/${d.name}`
427 }
428 rows.push({ name, depth, isDir: true, key: `dir:${d.key}` })
429 walk(d, depth + 1)
430 }
431 for (const f of [...node.files].sort((a, b) => a.name.localeCompare(b.name))) rows.push({ name: f.name, depth, isDir: false, key: f.key })
432 }
433 walk(root, 0)
434 return rows
435}
436
437/** The keys of the files beneath each folder row, at any depth: what a shut folder holds. */
438export function filesBeneath(rows: TreeRow[]): Map<string, string[]> {
439 const out = new Map<string, string[]>()
440 const path: TreeRow[] = []
441 for (const r of rows) {
442 // Rows come depth first: a row at or above a folder's depth has left it.
443 while ((path.at(-1)?.depth ?? -1) >= r.depth) path.pop()
444 if (r.isDir) {
445 out.set(r.key, [])
446 path.push(r)
447 } else for (const d of path) out.get(d.key)?.push(r.key)
448 }
449 return out
450}
451
452/** The entries under `folder` (a tree path such as `src/auth`), labelled relative to it; `''` keeps them all. */
453export function underFolder<V extends { label: string }>(entries: V[], folder: string, fold = false): V[] {
454 if (!folder) return entries
455 const pre = fold ? `${folder}/`.toLowerCase() : `${folder}/`
456 return entries.flatMap(v => {
457 const label = v.label.replace(/^\//, '')
458 return (fold ? label.toLowerCase() : label).startsWith(pre) ? [{ ...v, label: label.slice(pre.length) }] : []
459 })
460}
461
462/** The tree of a folder's entries, its folders keyed by their whole path (`dir:/src/auth`). */
463export function rowsUnder(entries: { key: string; label: string }[], folder: string, fold = false): TreeRow[] {
464 const rows = treeRows(entries, fold)
465 return folder ? rows.map(r => (r.isDir ? { ...r, key: `dir:/${folder}${r.key.slice('dir:'.length)}` } : r)) : rows
466}
467
468/** The keys of the folders a label sits in, as `rowsUnder` keys them: what must be open to show it. */
469export function folderKeys(label: string, folder: string, fold = false): string[] {
470 const segs = label.split('/').filter(Boolean).slice(0, -1).map(seg => (fold ? seg.toLowerCase() : seg))
471 const pre = folder ? [folder] : []
472 return segs.map((_, i) => `dir:/${[...pre, ...segs.slice(0, i + 1)].join('/')}`)
473}
474
475/** The tree path a folder row stands for: `dir:/src/auth` is `src/auth`. */
476export const folderOf = (key: string): string => key.replace(/^dir:\/?/, '')
477
478/** The rows a reader sees: nothing beneath a folder that is shut. */
479export function visibleRows(rows: TreeRow[], shut: ReadonlySet<string>): TreeRow[] {
480 const out: TreeRow[] = []
481 let hideBelow = -1
482 for (const r of rows) {
483 if (hideBelow >= 0 && r.depth > hideBelow) continue
484 hideBelow = r.isDir && shut.has(r.key) ? r.depth : -1
485 out.push(r)
486 }
487 return out
488}
489hooks/hotspot.tsx 96 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3// A region on the cell grid that takes presses, draws a few segments of text
4// and may let one pulse. The desktop rebuilds the pane on every redraw, several
5// a second while a crab walks: a Button pressed across a redraw loses its click,
6// and a picture's animation starts over. A Client keeps its state and its timer
7// through redraws and posts on the press itself, as the file rows do, so its
8// clicks land and its pulse runs on.
9
10export type HotSeg = { t: string; c?: string; b?: boolean; dim?: boolean; pulse?: boolean }
11export type HotspotProps = {
12 /** Posted as `{ press: id }` on a left press; absent, the region takes none. */
13 id?: string
14 /** One line of segments, or none; set to the right with `isRight`. */
15 line?: HotSeg[]
16 isRight?: boolean
17 /** The region's background while the pointer is over it. */
18 hoverBg?: string
19 /** Its background otherwise: set, the region reads as a button. */
20 bg?: string
21}
22type Local = { hover: boolean; phase: number; ref: { stop?: () => void } }
23
24const TICK_MS = 80
25const BEAT_MS = 1600
26// Halfway through a beat a pulsing segment has faded this far towards grey.
27const FADE = 0.65
28const GREY = [0x8a, 0x8a, 0x86] as const
29
30// `#rrggbb`, or the short `#rgb`.
31const rgb = (hex: string): number[] => {
32 const raw = hex.replace('#', '')
33 const h = raw.length === 3 ? [...raw].map(c => c + c).join('') : raw
34 return [0, 2, 4].map(i => Number.parseInt(h.slice(i, i + 2), 16) || 0)
35}
36
37// The colour a share `t` of the way from `hex` to grey.
38const toward = (hex: string, t: number): string =>
39 `#${rgb(hex)
40 .map((c, i) => Math.round(c + ((GREY[i] ?? c) - c) * t).toString(16).padStart(2, '0'))
41 .join('')}`
42
43const Hotspot: ClientModule<HotspotProps, Local> = (props, surface) => {
44 const { Box, Text } = surface.elements
45 let state = surface.state
46 if (state === undefined) {
47 state = { hover: false, phase: 0, ref: {} }
48 surface.setState(state)
49 }
50 const pulses = (props.line ?? []).some(s => s.pulse && s.c)
51 if (pulses && !state.ref.stop) {
52 state.ref.stop = surface.every(TICK_MS, () => {
53 const cur = surface.state
54 if (cur) surface.setState({ ...cur, phase: (cur.phase + 1) % (BEAT_MS / TICK_MS) })
55 })
56 } else if (!pulses && state.ref.stop) {
57 state.ref.stop()
58 state.ref.stop = undefined
59 }
60
61 surface.onPointer(e => {
62 const cur = surface.state ?? state
63 const isIn = e.type !== 'leave' && e.x >= 0 && e.y >= 0 && e.x < surface.columns && e.y < surface.rows
64 if (cur.hover !== isIn) surface.setState({ ...cur, hover: isIn })
65 // On the press itself, not the release: a release can fall into the next
66 // redraw and be lost, so there is no dragging off to cancel, as on the file rows.
67 if (e.type === 'down' && (e.button ?? 'left') === 'left' && props.id) surface.post({ press: props.id })
68 })
69 // With the keys, Enter or Space press it as a Button would.
70 surface.onKey(k => {
71 if (props.id && (k.key === 'return' || k.key === ' ' || k.key === 'space')) surface.post({ press: props.id })
72 })
73
74 // Out and back once a beat, easing at both ends.
75 const t = (1 - Math.cos(((state.phase * TICK_MS) / BEAT_MS) * 2 * Math.PI)) / 2
76 const draw = (s: HotSeg, k: string): RenderElement => (
77 <Text key={k} color={s.pulse && s.c ? toward(s.c, t * FADE) : s.c} bold={s.b} dimColor={s.dim}>
78 {s.t}
79 </Text>
80 )
81 return (
82 <Box
83 flexDirection="row"
84 width="100%"
85 height="100%"
86 justifyContent={props.isRight ? 'flex-end' : 'flex-start'}
87 alignItems="flex-start"
88 backgroundColor={state.hover && props.hoverBg ? props.hoverBg : props.bg}
89 >
90 <Text wrap="truncate-end">{(props.line ?? []).map((s, i) => draw(s, `s${i}`))}</Text>
91 </Box>
92 )
93}
94
95export default Hotspot
96hooks/progress.ts 99 lines1import type { CrewReport, CrewTask } from '../types'
2
3// Progress an agent shows of its own accord: the to-do list it keeps through
4// TodoWrite, or through TaskCreate and TaskUpdate. The mod only reads those
5// calls, so the bar fills for real without a tool or a token of its own
6// (decisions/no-token-cost.md).
7
8type Loose = Record<string, unknown>
9
10/** At most this many tasks are kept, across all agents. */
11export const MAX_TASKS = 200
12
13const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
14const STATUSES = ['pending', 'in_progress', 'completed'] as const
15type Status = (typeof STATUSES)[number]
16const statusOf = (v: unknown): Status | undefined => STATUSES.find(s => s === v)
17
18/** A step's line is cut to this many characters, as a `step` report's note is. */
19const MAX_NOTE = 80
20
21// A step's line, on one line: what it is doing, else what it is.
22const noteOf = (item: { activeForm?: string; subject?: string; content?: string } | undefined): string | undefined => {
23 const text = item ? (str(item.activeForm) || str(item.subject) || str(item.content)).replace(/\s+/g, ' ') : ''
24 if (!text) return undefined
25 return [...text].length > MAX_NOTE ? `${[...text].slice(0, MAX_NOTE - 1).join('')}…` : text
26}
27
28/** The progress a list of steps shows: done of all, and the one under way; none for an empty list. */
29export function reportOf(steps: readonly { status: Status; activeForm?: string; subject?: string; content?: string }[]): CrewReport | undefined {
30 if (!steps.length) return undefined
31 const done = steps.filter(s => s.status === 'completed').length
32 const note = noteOf(steps.find(s => s.status === 'in_progress'))
33 return note ? { done, total: steps.length, note } : { done, total: steps.length }
34}
35
36/** A TodoWrite call's progress: the list it writes is the whole list, and an empty one clears it. */
37export function todoReport(input: Loose): CrewReport | undefined {
38 if (!Array.isArray(input.todos)) return undefined
39 const steps = input.todos.flatMap(t => {
40 const o = (t ?? {}) as Loose
41 const status = statusOf(o.status)
42 return status ? [{ status, activeForm: str(o.activeForm), content: str(o.content) }] : []
43 })
44 return reportOf(steps)
45}
46
47/** The id a TaskCreate or TaskUpdate call is about: the result's id, else the subject; the update's `taskId`. */
48export function taskIdOf(tool: string, input: Loose, out: Loose | undefined): string {
49 if (tool === 'TaskCreate') return str(((out?.task ?? {}) as Loose).id) || str(input.subject)
50 return tool === 'TaskUpdate' ? str(input.taskId) : ''
51}
52
53/**
54 * The session's tasks after a TaskCreate or TaskUpdate call by `agent`. The
55 * list is one for all agents, as the engine's is: a new task joins as pending,
56 * made by `agent`; an update from any agent changes what it names, its `owner`
57 * too; a deleted task leaves. Any other call leaves the list as it was.
58 */
59export function applyTask(list: readonly CrewTask[], tool: string, input: Loose, out: Loose | undefined, agent: string): CrewTask[] {
60 const id = taskIdOf(tool, input, out)
61 if (tool === 'TaskCreate') {
62 const subject = str(input.subject)
63 if (!subject || !id) return [...list]
64 const activeForm = str(input.activeForm) || undefined
65 return [...list.filter(t => t.id !== id), { id, subject, ...(activeForm ? { activeForm } : {}), status: 'pending' as const, agent }].slice(-MAX_TASKS)
66 }
67 if (tool === 'TaskUpdate') {
68 if (!id || !list.some(t => t.id === id)) return [...list]
69 if (input.status === 'deleted') return list.filter(t => t.id !== id)
70 return list.map(t => {
71 if (t.id !== id) return t
72 const next: CrewTask = {
73 ...t,
74 ...(str(input.subject) ? { subject: str(input.subject) } : {}),
75 ...(str(input.activeForm) ? { activeForm: str(input.activeForm) } : {}),
76 ...(statusOf(input.status) ? { status: statusOf(input.status) as Status } : {}),
77 }
78 // A new owner takes the task over; an empty one hands it back to its maker.
79 if (typeof input.owner === 'string') {
80 const owner = str(input.owner)
81 if (owner) next.owner = owner
82 else delete next.owner
83 }
84 return next
85 })
86 }
87 return [...list]
88}
89
90/** Whose a task is: its owner's, resolved to an agent by `resolve`, else its maker's. */
91export const holderOf = (t: CrewTask, resolve: (owner: string) => string): string => (t.owner ? resolve(t.owner) : t.agent)
92
93/** The progress of the tasks `holder` has. */
94export const tasksReport = (list: readonly CrewTask[], holder: string, resolve: (owner: string) => string): CrewReport | undefined =>
95 reportOf(list.filter(t => holderOf(t, resolve) === holder))
96
97/** Whether a call is one of the to-do tools this module reads. */
98export const isTodoTool = (tool: string): boolean => tool === 'TodoWrite' || tool === 'TaskCreate' || tool === 'TaskUpdate'
99hooks/tabs.tsx 113 lines1import type { ClientModule, RenderElement } from 'claude-code'
2
3// Browser-style tabs on the cell grid: rounded tops, the active tab open at the
4// bottom so it runs into the content line, the hovered one lit. A click posts
5// `{ tab }` to the hooks module.
6//
7// ╭───────────╮╭────────────╮
8// │ ● Aktiv 2 ││ ✓ Fertig 5 │
9// ╯ ╰┴────────────┴──────────────
10
11export type Tab = { id: string; icon: string; label: string; count: number; color: string }
12export type TabsProps = { tabs: Tab[]; active: string; line: string; dim: string }
13type Local = { hover: number }
14
15const GUTTER = 1
16
17const labelOf = (t: Tab): string => ` ${t.icon} ${t.label} ${t.count} `
18
19// The column span [from, to) each tab takes, after the gutter.
20const spans = (tabs: Tab[]): [number, number][] => {
21 let x = GUTTER
22 return tabs.map(t => {
23 const w = [...labelOf(t)].length + 2
24 const span: [number, number] = [x, x + w]
25 x += w
26 return span
27 })
28}
29
30const Tabs: ClientModule<TabsProps, Local> = (props, surface) => {
31 const { Box, Text } = surface.elements
32 const state = surface.state ?? { hover: -1 }
33 const at = spans(props.tabs)
34 const hit = (x: number, y: number) => (y >= 0 && y <= 2 ? at.findIndex(([a, b]) => x >= a && x < b) : -1)
35
36 surface.onPointer(e => {
37 const cur = surface.state ?? { hover: -1 }
38 if (e.type === 'leave') {
39 if (cur.hover !== -1) surface.setState({ hover: -1 })
40 return
41 }
42 const i = hit(e.x, e.y)
43 if (e.type === 'move' && i !== cur.hover) surface.setState({ hover: i })
44 if (e.type === 'down' && (e.button ?? 'left') === 'left' && i >= 0) {
45 const tab = props.tabs[i]
46 if (tab && tab.id !== props.active) surface.post({ tab: tab.id })
47 }
48 })
49
50 const used = at.at(-1)?.[1] ?? GUTTER
51 const tail = Math.max(0, surface.columns - used)
52 const top: RenderElement[] = [<Text key="t-g">{' '.repeat(GUTTER)}</Text>]
53 const mid: RenderElement[] = [<Text key="m-g">{' '.repeat(GUTTER)}</Text>]
54 const bottom: RenderElement[] = [
55 <Text key="b-g" color={props.line}>
56 {'─'.repeat(GUTTER)}
57 </Text>,
58 ]
59
60 props.tabs.forEach((t, i) => {
61 const isActive = t.id === props.active
62 const isHover = i === state.hover && !isActive
63 const inner = [...labelOf(t)].length
64 const edge = isActive ? t.color : isHover ? props.line : props.dim
65 top.push(
66 <Text key={`t-${t.id}`} color={edge}>
67 {`╭${'─'.repeat(inner)}╮`}
68 </Text>,
69 )
70 mid.push(
71 <Text key={`m-${t.id}`}>
72 <Text color={edge}>│</Text>
73 <Text color={isActive ? t.color : undefined} bold={isActive} dimColor={!isActive && !isHover}>
74 {` ${t.icon} `}
75 </Text>
76 <Text bold={isActive} dimColor={!isActive && !isHover}>
77 {t.label}
78 </Text>
79 <Text color={isActive ? t.color : undefined} dimColor={!isActive}>
80 {` ${t.count} `}
81 </Text>
82 <Text color={edge}>│</Text>
83 </Text>,
84 )
85 bottom.push(
86 isActive ? (
87 <Text key={`b-${t.id}`} color={t.color}>
88 {`╯${' '.repeat(inner)}╰`}
89 </Text>
90 ) : (
91 <Text key={`b-${t.id}`} color={props.line}>
92 {`┴${'─'.repeat(inner)}┴`}
93 </Text>
94 ),
95 )
96 })
97 bottom.push(
98 <Text key="b-tail" color={props.line}>
99 {'─'.repeat(tail)}
100 </Text>,
101 )
102
103 return (
104 <Box flexDirection="column">
105 <Text wrap="truncate-end">{top}</Text>
106 <Text wrap="truncate-end">{mid}</Text>
107 <Text wrap="truncate-end">{bottom}</Text>
108 </Box>
109 )
110}
111
112export default Tabs
113types/index.d.ts 196 lines1/** Where a run stands: `idle` is a teammate or background agent waiting for a message. */
2export type CrewStatus = 'running' | 'idle' | 'done' | 'failed' | 'stopped'
3
4/** How the run was started. `listed` came from `$.agent.list()` without a spawn the mod saw. */
5export type CrewKind = 'agent' | 'fork' | 'teammate' | 'workflow' | 'listed'
6
7export type CrewRun = {
8 /** The engine's agent id, or the spawn's tool_use_id when the engine gave none. */
9 id: string
10 agentId?: string
11 kind: CrewKind
12 type: string
13 description: string
14 /** The task as given, cut to a few thousand characters. */
15 prompt?: string
16 name?: string
17 model: string
18 effort?: string
19 isBackground: boolean
20 parentId?: string
21 workflowRunId?: string
22 workflowIndex?: number
23 status: CrewStatus
24 startedAt: number
25 endedAt?: number
26 contextTokens: number
27 contextMax: number
28 tokens: number
29 costUsd: number
30 steps: number
31 /** Tokens the cache served across its requests; `tokens` counts the new ones. */
32 cacheTokens?: number
33 /** The last thing it did, one line (`Read hooks/files.ts`). */
34 lastAction?: string
35 /** Progress the agent shows itself: its to-do list (`TodoWrite`, `TaskCreate`/`TaskUpdate`) or savvy-progress's `step` tool; steps done of planned, and the one under way. */
36 report?: CrewReport
37 /** Its final answer, cut to a few thousand characters. */
38 lastAnswer?: string
39}
40
41/** One entry of a subagent's activity log: a tool call, or text it wrote between calls. */
42export type CrewLog = {
43 at: number
44 kind: 'tool' | 'say'
45 tool?: string
46 text: string
47 isError?: boolean
48}
49
50export type CrewTab = 'active' | 'done'
51
52/** Steps done of planned, and the one under way. */
53export type CrewReport = { done: number; total: number; note?: string }
54
55/**
56 * A task of the session's shared list, kept through `TaskCreate` and `TaskUpdate` as far as the calls tell:
57 * `agent` made it (`main` for the main chat), `owner` was given it by an update.
58 */
59export type CrewTask = { id: string; subject: string; activeForm?: string; status: 'pending' | 'in_progress' | 'completed'; agent: string; owner?: string }
60
61/** One turn of the main chat, from `turn.start` to its `turn.complete`; open while it runs. */
62export type CrewMainTurn = { startedAt: number; endedAt?: number; reason?: 'answer' | 'aborted' | 'refusal' | 'error' }
63
64export type CrewPanel = {
65 tab: CrewTab
66 expanded: string[]
67 isCompact: boolean
68 hasAutoOpened: boolean
69 /** What the file tree shows: what the session's agents read and changed, the changes alone, or every file. */
70 fileFilter: 'activity' | 'changed' | 'all'
71 isFilesCollapsed: boolean
72 /** The file whose history and diff are open. */
73 openFile?: string
74 /** The tab of an open file's card: its history or its changes. */
75 fileTab?: 'history' | 'changes'
76 /** Folders of the file tree opened by a press; every other one is shut. */
77 openDirs?: string[]
78 /** When a folder was shut by a press: a file touched before that no longer opens it. */
79 shutAt?: Record<string, number>
80 /** The folder the file tree shows (`src/auth`), entered by a double press; absent, the whole tree. */
81 fileRoot?: string
82 /** What the search line above the file tree holds. */
83 fileQuery?: string
84 /** Whether the search line holds the keys, as far as the hooks can tell. */
85 isSearchFocused?: boolean
86 /** An agent whose files are lit while the rest dim. */
87 focusAgent?: string
88 isTimelineOpen: boolean
89}
90
91/** What "Clear finished" took off the list, so the session totals stay whole. */
92export type CrewCleared = {
93 cost: number
94 tokens: number
95 cache?: number
96 /** Agents cleared or trimmed from the list, so the engine's list does not bring them back. */
97 ids?: string[]
98}
99
100/** What the update check found: this copy's version, the published one, where this copy loads from. */
101export type CrewUpdate = {
102 current: string
103 latest?: string
104 /** `marketplace`: an installed copy, updated by `claude plugin update`; `folder`: loaded from a folder of its own (a working copy). */
105 source: 'marketplace' | 'folder'
106 root: string
107 /** Where an update stands: being checked or installed, failed with a reason, or installed. */
108 phase?: 'checking' | 'updating' | 'failed' | 'done'
109 error?: string
110}
111
112export type CrewFile = {
113 /** Case-folded on Windows; what git status and tool calls are matched by. */
114 key: string
115 path: string
116 reads: number
117 writes: number
118 lastAt: number
119 lastKind: 'read' | 'write' | 'commit'
120 /** Agent ids that touched it; `main` for the main conversation. */
121 agents: string[]
122 commit?: string
123 commitAt?: number
124 /** Who read, changed or committed it and when, newest last. */
125 history?: CrewFileEvent[]
126 /** Agents that changed it close together; cleared when it is committed. */
127 conflict?: string[]
128 /** Its writes within the conflict window since the last commit, apart from the shown history, which reads push out. */
129 recentWrites?: { at: number; agent: string }[]
130}
131
132export type CrewFileEvent = {
133 at: number
134 agent: string
135 kind: 'read' | 'write' | 'commit'
136}
137
138export type CrewGitFile = {
139 path: string
140 /** Porcelain XY: ` M`, `M `, `??`, `A `, ` D`, ... */
141 code: string
142 add: number
143 del: number
144}
145
146export type CrewGit = {
147 root: string
148 branch: string
149 upstream: string
150 ahead: number
151 behind: number
152 head: string
153 files: Record<string, CrewGitFile>
154}
155
156/** The session's context fill and the plan's rate-limit windows, as the status line has them. */
157export type CrewUsage = {
158 context: { percent: number | null; tokens?: number; window: number }
159 limits: { kind: string; percent: number; resetsAt?: string }[]
160}
161
162declare module 'claude-code' {
163 interface PluginState {
164 'agent-crew': {
165 runs: CrewRun[]
166 logs: Record<string, CrewLog[]>
167 panel: CrewPanel
168 cleared: CrewCleared
169 files: CrewFile[]
170 git: CrewGit | null
171 now: number
172 usage: CrewUsage
173 /** The diff of the open file, as git printed it (or its text when untracked). */
174 diff: { key: string; text: string; format: 'diff' | 'source' } | null
175 /** The highest level each usage meter was warned at (0, 80, 95). */
176 warned: Record<string, number>
177 /** Bumped on each change from the settings pane, so both panes draw again. */
178 settingsRev: number
179 /** The main chat's own requests since the mod loaded: estimated cost and tokens. */
180 mainCost: { cost: number; tokens: number; cache?: number }
181 /** The running version and the newest one published, for the update notice and the settings. */
182 update: CrewUpdate | null
183 /** Every file under the root, for the tree's "All" filter: git's own list, or a bounded walk. */
184 tree: { root: string; paths: string[] } | null
185 /** The colour each agent type outside Claude Code's own was given when first seen, by lower-case bare type. */
186 typeColors: Record<string, string>
187 /** The main chat's turns, oldest first, for the timeline: when each began and ended, and how. */
188 mainTurns: CrewMainTurn[]
189 /** The session's tasks from `TaskCreate`/`TaskUpdate`, one list for all agents, as the engine keeps them. */
190 tasks: CrewTask[]
191 /** The main chat's progress through its to-do list, for the timeline; null without one. */
192 mainReport: CrewReport | null
193 }
194 }
195}
196