SLOPSHOPPER

agent-crew

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

newpanebandguardcommandtoast
★ 1v0.8.1PolyForm-Noncommercial-1.0.0updated 2026-10-11jduerrmann/agent-crew/plugins/agent-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-crew
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ ⚙ Settings │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ Usage [ /compact ] │ ⎿ Read 6 lines │ ┃ │ ███████████░░░░░░░░░░░ 49% context · 9… │ ⏺ Update(src/auth.ts) │ ┃ │ ███████░░░░░░░░░░░░░░░ 31% 5h │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ [ ⤡ Compact ] │ │ ┃ │ ▣ client module ./tabs.tsx │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ No subagent is running. │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▾ Timeline │ › /crew │ ┃ │ main chat ━━━━················… │ ⎿ agent-crew: Crew panel opened. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▾ Files · 4 │ [ Activity ] [ Changes ] │ ┃ │ │ ┃ │ ▣ client module ./searchbox.tsx │ ┃ │ │ ┃ │ ⌂ app ⑂ HEAD │ ┃ │ │ ┃ │ ▣ client module ./filerows.tsx │ ┃ │ │ ┃ │ ──────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
⚙ Settings ╭──────────────────────────────────────────────────────────╮ │ Usage [ /compact ] │ │ ███████████░░░░░░░░░░░ 49% context · 97k / 200k │ │ ███████░░░░░░░░░░░░░░░ 31% 5h │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ [ ⤡ Compact ] │ │ ▣ client module ./tabs.tsx │ │ No subagent is running. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Timeline │ │ main chat ━━━━···························· │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Files · 4 │ [ Activity ] [ Changes ] [ All ] ⊟ │ │ │ │ ▣ client module ./searchbox.tsx │ │ │ │ ⌂ app ⑂ HEAD │ │ │ │ ▣ client module ./filerows.tsx │ │ │ │ ──────────────────────────────────────────────────── │ │ ◉ read ✎ changed + new ✓ committed ✗ deleted │ ╰──────────────────────────────────────────────────────────╯
Pane · Crew · Settings
╭─────────────────────────────────────────────────────────── │ Sections │ │ Usage [ ● │ Context and limits at the top of the panel; the Usage │ group picks which │ │ Cost [ ○ │ Main chat and subagents apart, with tokens and a total │ │ Subagents [ ● On ] │ Running and finished subagents, with details │ │ Timeline [ ● On ] │ When each agent ran, on one shared axis │ │ Main chat in the timeline [ ● On ] │ A row at the top: when the main chat worked │ │ Files [ ● │ Files read, changed and committed, as a tree with diffs │ ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Usage │ │ │ │ Context [ ● On ] │ │ How full the session’s context window is │ │ │ │ 5-hour limit [ ● On ] │ │ Use so far and time until it resets │ │ │ │ Weekly limit [ ● On ] │ │ Use so far and time until it resets │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Above the prompt │ │ Crew line [ ● │ Running, finished and failed subagents and conflicts, │ above the prompt │ │ Progress [ ○ Of │ Each running subagent with its bar, above the prompt │ │ Usage [ │ Context and limits with the time until they reset, above │ the prompt │ │ Status line [ ● On │ How many subagents run, in Claude Code’s status line │ ╰─────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ General │ │ Open on subagents [ ● O │ The panel opens when the first subagent of a session │ starts │ │ Open on file activity [ │ The panel opens when a file is first read or changed in │ a session │ │ Language │ Automatic follows Claude Code’s language, then the │ system │ [ ✓ Automatic ] [ Deutsch ] [ English ] │ │ Crab animation │ Smooth: on the desktop the panel redraws four times a │ second while an agent runs. Light: once a second, and │ the crabs’ step starts over each time │ [ ✓ Smooth ] [ Light ] │ ╰─────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ Notifications │ │ Agent finished │ When a notice comes as a subagent ends │ [ Off ] [ Failures only ] [ ✓ Background ] [ All ] │ │ Notices as │ Inside Claude Code, or as the terminal’s or system’s │ notification │ [ ✓ In Claude Code ] [ System ] │ │ Limit warnings [ ● On ] │ At 80 % and 95 % of a limit, and at 85 % context │ │ Conflict warning [ ● │ When two agents change the same file within 10 minutes │ │ Update notice [ │ Look once a day for a new version of agent-crew, and say │ so │ ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Version │ │ │ │ agent-crew [ Check for updates ] │ │ │ ╰──────────────────────────────────────────────────────────╯ The same values as /config. Changes apply at once.
README

agent-crew

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.

Sections

Each section can be switched on or off in the settings.

  • Usage: context fill and the plan's 5-hour and weekly limits with their reset times, and a /compact button. Each meter can be hidden on its own. Warnings when a limit passes 80 % and 95 %, and when the context passes 85 %.
  • Cost (off by default): main chat and subagents separately, new and cached tokens, and the total. Costs are estimated from the PRICES table in hooks/register.tsx.
  • Subagents: every subagent, whichever way it started (Agent tool, forks, teammates, background and workflow agents, nested agents, and agents only $.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.
  • Timeline: when each agent ran, on one time axis, with long pauses folded; the main chat's turns in a row at the top (turn.start to turn.complete).
  • Above the prompt: a band with the crew (running, finished, failed, conflicts), and optionally each running agent's bar and the usage. A click opens the pane. A band another plugin draws stays, beneath it.
  • Files: a tree of the files read, changed and committed during the session, plus the files git reports as changed: ◉ read, ✎ changed, + new, ✓ committed, ✗ deleted, each in a colour of its own. Shut folders sum up what is inside them. Clicking a file shows its history and its diff against 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.

Options

/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.

OptionDefaultDescription
showUsagetrueUsage section
showTotalsfalseCost section
showAgentstrueSubagents section
showTimelinetrueTimeline section
timelineMaintrueThe main chat's turns in a row at the top of the timeline
showFilestrueFiles section
usageContexttrueContext meter
usageFiveHourtrue5-hour limit meter
usageWeektrueWeekly limit meters
bandAgentstrueAbove the prompt: running, finished and failed subagents and conflicts; a click opens the pane
bandProgressfalseAbove the prompt: each running subagent with its bar
bandUsagefalseAbove the prompt: context and limits, with the time until they reset, and a /compact button
statusLinetrueThe status line entry with the number of running subagents
autoOpentrueOpen the pane on the first subagent of a session
autoOpenFilestrueOpen the pane on the first file read or changed in a session
languageautoauto, de or en
crabMotionsmoothsmooth: 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
notifyAgentsbackgroundoff, failures, background, all
notifyViatoasttoast, or system for the terminal's or system's notification
limitWarningstrueWarnings at 80 % and 95 % of a limit and at 85 % context
warnConflictstrueNotice on conflicting edits
checkUpdatestrueDaily check for a new version; the settings pane installs it

Install

claude plugin marketplace add jduerrmann/agent-crew
claude plugin install agent-crew@agent-crew

Acknowledgements

Both are MIT licensed; see NOTICE. agent-crew itself is licensed under PolyForm Noncommercial 1.0.0.

Source 9 files
hooks/register.tsx 3744 lines
1import { 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 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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>`
157
hooks/filerows.tsx 89 lines
1import 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
89
hooks/searchbox.tsx 79 lines
1import 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
79
hooks/files.ts 489 lines
1// 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}
489
hooks/hotspot.tsx 96 lines
1import 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
96
hooks/progress.ts 99 lines
1import 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'
99
hooks/tabs.tsx 113 lines
1import 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
113
types/index.d.ts 196 lines
1/** 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