Live project file tree showing which file each agent (main, subagents, cheap workers) is editing (red π¨) and what's ready for review (green β)

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What it does |
|---|---|
cheap-worker | /worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage). |
agent-progress | /progress: animated progress bar per agent (main, subagents, workers) from their task lists. |
agent-map | /map: project file tree; red π¨ = an agent is editing it, green β = done, ready for review. |
context-bar | Context-window breakdown above the prompt. |
usage-meter | 5-hour / weekly usage in the footer. |
quick-settings | β footer button for model and effort. |
skins | /skins: colour themes (Tokyo Night, Dracula, Gruvbox, β¦). |
cw-pane, driver-seat-guard, evidence-band | For the driver-seat workflow; skip them elsewhere. |
/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods
On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, β¦) applies. No call to openrouter.ai. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were). |
Work laptop: provider = bedrock, launch = tabs.
crewThe mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).
crew # list running workers, pick one by number
crew ab12c # attach to cw-ab12c
If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.
Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew β¦)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentKind, MapAgent, MapState } from '../types'
5import { EDIT_TOOLS, META_FORMAT, editPath, fileState, finish, isOutside, isWorking, parseMeta, projectDir, relTo, rows, touch, workerEdits } from './map'
6
7const PANE = 'agent-map'
8const RED = '#e5534b'
9const GREEN = '#57ab5a'
10const DIM = '#768390'
11// Clawd's head, as Claude Code draws it at startup; one colour per kind of agent.
12const CLAWD = 'βββββ'
13const KIND_COLOR: Record<AgentKind, string> = { main: '#d97757', sub: '#b083f0', worker: '#c8f031' }
14
15const DEFAULTS: MapState = { agents: [], edits: {}, reviewed: {}, files: [], open: [], shut: [] }
16const map = atom({ plugin: 'agent-map', key: 'map' } as const, DEFAULTS)
17
18const transcripts = new Map<string, string>()
19
20async function run($: EngineInterface, argv: string[]) {
21 const r = await $.process.run(argv).catch(() => null)
22 return r?.exitCode === 0 ? r.stdout : ''
23}
24
25/** The project's files: git's list (ignores respected), else a shallow find. */
26async function loadFiles($: EngineInterface) {
27 const cwd = await $.session.cwd()
28 let out = await run($, ['git', '-C', cwd, 'ls-files'])
29 if (!out) {
30 const found = await run($, ['find', cwd, '-maxdepth', '5', '-type', 'f', '-not', '-path', '*/.git/*', '-not', '-path', '*/node_modules/*'])
31 out = found.split('\n').filter(Boolean).map(p => relTo(cwd, p)).join('\n')
32 }
33 // ponytail: 20k files max; past that the tree is unreadable anyway
34 const files = out.split('\n').filter(Boolean).slice(0, 20_000)
35 await update($, map, m => ({ ...m, files }))
36}
37
38async function openPane($: EngineInterface) {
39 await $.ui.open({ id: PANE, title: 'Agent map' })
40 void loadFiles($)
41 void refresh($)
42}
43
44/** Every 5s: subagents that finished go green; workers' edits from their transcripts. */
45async function refresh($: EngineInterface) {
46 const cwd = await $.session.cwd()
47 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
48 const subs = await $.agent.list().catch(() => [])
49 const running = new Set(subs.filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').map(a => `s:${a.id}`))
50 for (const a of (await read($, map)).agents) {
51 if (a.kind === 'sub' && a.file !== null && !running.has(a.id)) await update($, map, m => finish(m, a.id))
52 }
53
54 const panes = parseMeta(await run($, ['tmux', 'list-panes', '-a', '-F', META_FORMAT]))
55 const live = new Set<string>()
56 for (const p of panes) {
57 const id = `w:${p.name}`
58 live.add(id)
59 let file = transcripts.get(p.name)
60 if (!file && home && p.cwd) {
61 file = (await run($, ['grep', '-rl', '--include=*.jsonl', '-e', `"agentName":"${p.name}"`, projectDir(home, p.cwd)])).split('\n').find(Boolean)
62 if (file) transcripts.set(p.name, file)
63 }
64 if (!file) continue
65 // ponytail: last 2MB of the transcript; older edits drop out of the map
66 const edits = workerEdits(await run($, ['tail', '-c', '2000000', file]))
67 const isBusy = isWorking(await run($, ['tmux', 'capture-pane', '-p', '-J', '-t', p.pane]))
68 await update($, map, m => {
69 let next = m
70 for (const e of edits) {
71 const path = relTo(cwd, e.path)
72 if ((next.edits[path]?.at ?? 0) < e.at) next = touch(next, { id, name: p.name, kind: 'worker' }, path, e.at)
73 }
74 const last = edits.at(-1)
75 const want = isBusy && last ? relTo(cwd, last.path) : null
76 const cur = next.agents.find(a => a.id === id)
77 if (cur && cur.file !== want) next = { ...next, agents: next.agents.map(a => (a.id === id ? { ...a, file: want } : a)) }
78 return next
79 })
80 }
81 for (const a of (await read($, map)).agents) {
82 if (a.kind === 'worker' && a.file !== null && !live.has(a.id)) await update($, map, m => finish(m, a.id))
83 }
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 await $.command.register({ name: 'map', description: 'Agent map: which files agents are editing (/map, /map clear = mark all reviewed)' })
89 await update($, map, m => ({ ...DEFAULTS, ...m }))
90 $.clock.every(5000, () => void refresh($))
91 return next(e)
92 })
93
94 on('command.run', { command: 'map' }, async ($, e) => {
95 if (e.args.trim() === 'clear') {
96 const now = await $.clock.now()
97 await update($, map, m => ({ ...m, reviewed: { ...m.reviewed, ...Object.fromEntries(Object.keys(m.edits).map(p => [p, now])) } }))
98 return { text: 'Agent map: every edited file marked reviewed.' }
99 }
100 await openPane($)
101 return { text: 'Agent map open.' }
102 })
103
104 // Main agent and subagents: an answered edit moves the agent onto that file.
105 on('tool.call', async ($, e, next) => {
106 const r = await next(e)
107 if (!EDIT_TOOLS.includes(e.tool) || ('deny' in r && r.deny) || ('isError' in r && r.isError)) return r
108 const abs = editPath(e)
109 if (!abs) return r
110 const path = relTo(await $.session.cwd(), abs)
111 const now = await $.clock.now()
112 let agent: Omit<MapAgent, 'file'> = { id: 'main', name: 'Claude', kind: 'main' }
113 if (e.agentId) {
114 const info = (await $.agent.list().catch(() => [])).find(a => a.id === e.agentId)
115 agent = { id: `s:${e.agentId}`, name: info?.description || info?.name || 'subagent', kind: 'sub' }
116 }
117 await update($, map, m => touch(m, agent, path, now))
118 return r
119 }).catch(($, e, next) => next(e)) // the map is cosmetic: fail open
120
121 on('turn.complete', async ($, e, next) => {
122 const r = await next(e)
123 await update($, map, m => finish(m, 'main'))
124 return r
125 })
126
127 // ββ footer chip: only while something is red or green βββββββββββββββββββ
128 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
129 const below = await next(e)
130 const { Box, Button } = $.ui.resolve(e)
131 const m = await read($, map)
132 const paths = Object.keys(m.edits)
133 const red = paths.filter(p => fileState(m, p) === 'active').length
134 const green = paths.filter(p => fileState(m, p) === 'done').length
135 if (red + green === 0) return below
136 const label = [red ? `π¨ ${red}` : '', green ? `β ${green}` : ''].filter(Boolean).join(' Β· ')
137 return (
138 <Box justifyContent="space-between">
139 <Box flexGrow={1}>{below}</Box>
140 <Box key="map-chip" marginLeft={2}>
141 <Button key="map-open" plain label={label} hover={{ color: GREEN, bold: true }} onPress={() => openPane($)} />
142 </Box>
143 </Box>
144 )
145 })
146
147 // ββ pane ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
148 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
149 const { Box, Button, Text } = $.ui.resolve(e)
150 const m = await read($, map)
151 const now = await $.clock.now()
152 const cwd = await $.session.cwd()
153 const tree = rows(m)
154 const outside = Object.keys(m.edits).filter(p => isOutside(p) && fileState(m, p) !== null)
155 const agentsOn = (path: string) => m.agents.filter(a => a.file === path)
156 const review = (path: string) => update($, map, x => ({ ...x, reviewed: { ...x.reviewed, [path]: now } }))
157 const toggle = (path: string, isOpen: boolean) =>
158 update($, map, x => ({
159 ...x,
160 open: isOpen ? x.open.filter(p => p !== path) : [...x.open, path],
161 shut: isOpen ? [...x.shut, path] : x.shut.filter(p => p !== path),
162 }))
163 const red = Object.keys(m.edits).filter(p => fileState(m, p) === 'active').length
164 const green = Object.keys(m.edits).filter(p => fileState(m, p) === 'done').length
165
166 const fileLine = (key: string, path: string, name: string, depth: number) => {
167 const state = fileState(m, path)
168 const agents = agentsOn(path)
169 return (
170 <Box key={key} paddingLeft={depth * 2} columnGap={1}>
171 <Text color={state === 'active' ? RED : state === 'done' ? GREEN : undefined} dimColor={state === null} bold={state !== null}>
172 {name}
173 </Text>
174 {state === 'active' ? <Text>π¨</Text> : null}
175 {agents.map(a => (
176 <Text key={`ag-${a.id}`}>
177 <Text color={KIND_COLOR[a.kind]}>{CLAWD}</Text>
178 {a.kind === 'main' ? '' : <Text color={DIM}>{` ${a.name}`}</Text>}
179 </Text>
180 ))}
181 {state === 'done' ? <Text color={GREEN}>β</Text> : null}
182 {state === 'done' ? <Button key={`rv-${path}`} plain dimColor label="Γ" hover={{ color: GREEN, bold: true }} onPress={() => review(path)} /> : null}
183 </Box>
184 )
185 }
186
187 return (
188 <Box flexDirection="column" paddingX={1}>
189 <Box key="legend" columnGap={2}>
190 <Text color={RED}>{`π¨ ${red} editing`}</Text>
191 <Text color={GREEN}>{`β ${green} ready for review`}</Text>
192 {green > 0 ? (
193 <Button
194 key="review-all"
195 plain
196 dimColor
197 label="mark all reviewed"
198 hover={{ color: GREEN, bold: true }}
199 onPress={() => update($, map, x => ({ ...x, reviewed: { ...x.reviewed, ...Object.fromEntries(Object.keys(x.edits).map(p => [p, now])) } }))}
200 />
201 ) : null}
202 </Box>
203 <Box key="agents" columnGap={2} marginBottom={1}>
204 {m.agents
205 .filter(a => a.file !== null)
206 .map(a => (
207 <Text key={`who-${a.id}`}>
208 <Text color={KIND_COLOR[a.kind]}>{CLAWD}</Text>
209 <Text color={DIM}>{` ${a.name} β ${a.file}`}</Text>
210 </Text>
211 ))}
212 </Box>
213 <Text key="root" bold>{`${cwd.split('/').pop() || cwd}/`}</Text>
214 {tree.rows.length === 0 ? <Text key="empty" dimColor>Loading the file listβ¦</Text> : null}
215 {tree.rows.map(r =>
216 r.isDir ? (
217 <Box key={r.key} paddingLeft={r.depth * 2}>
218 <Button
219 key={`dir-${r.path}`}
220 plain
221 label={r.isOpen ? `βΎ ${r.name}/` : `βΈ ${r.name}/ (${r.count})`}
222 hover={{ bold: true }}
223 onPress={() => toggle(r.path, r.isOpen)}
224 />
225 </Box>
226 ) : (
227 fileLine(r.key, r.path, r.name, r.depth)
228 ),
229 )}
230 {tree.isCut ? <Text key="cut" dimColor>β¦ more files (collapse a folder to see the rest)</Text> : null}
231 {outside.length > 0 ? (
232 <Box key="outside" flexDirection="column" marginTop={1}>
233 <Text bold color={DIM}>OUTSIDE THIS PROJECT</Text>
234 {outside.map(p => fileLine(`o:${p}`, p, p, 0))}
235 </Box>
236 ) : null}
237 </Box>
238 )
239 })
240}
241hooks/map.ts 126 lines1import type { AgentKind, MapState } from '../types'
2
3export const EDIT_TOOLS: readonly string[] = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
4
5/** The file an edit tool's input names. */
6export function editPath(input: unknown): string | null {
7 const i = (input ?? {}) as { file_path?: unknown; notebook_path?: unknown }
8 const p = i.file_path ?? i.notebook_path
9 return typeof p === 'string' && p ? p : null
10}
11
12/** `abs` relative to `cwd`, or `abs` itself when it's outside the project. */
13export function relTo(cwd: string, abs: string): string {
14 const root = cwd.endsWith('/') ? cwd : `${cwd}/`
15 return abs.startsWith(root) ? abs.slice(root.length) : abs
16}
17export const isOutside = (path: string) => path.startsWith('/')
18
19/** `agent` starts editing `path`: whatever it was on before is done (green) from here. */
20export function touch(m: MapState, agent: { id: string; name: string; kind: AgentKind }, path: string, at: number): MapState {
21 const prev = m.edits[path]
22 const others = m.agents.filter(a => a.id !== agent.id)
23 return {
24 ...m,
25 agents: [...others, { ...agent, file: path }],
26 edits: { ...m.edits, [path]: { at, by: [...new Set([...(prev?.by ?? []), agent.id])] } },
27 }
28}
29
30/** `agent` is finished: its file goes green, its icon goes away. */
31export const finish = (m: MapState, id: string): MapState =>
32 m.agents.some(a => a.id === id && a.file !== null) ? { ...m, agents: m.agents.map(a => (a.id === id ? { ...a, file: null } : a)) } : m
33
34export type FileState = 'active' | 'done' | null
35export function fileState(m: MapState, path: string): FileState {
36 if (m.agents.some(a => a.file === path)) return 'active'
37 const e = m.edits[path]
38 return e && e.at > (m.reviewed[path] ?? -Infinity) ? 'done' : null
39}
40
41/** Every edit in a worker's transcript (tail), oldest first, with its time. */
42export function workerEdits(jsonl: string): { path: string; at: number }[] {
43 const out: { path: string; at: number }[] = []
44 for (const line of jsonl.split('\n')) {
45 if (!line.startsWith('{') || !line.includes('tool_use')) continue
46 let row: { timestamp?: string; message?: { content?: unknown } }
47 try {
48 row = JSON.parse(line)
49 } catch {
50 continue
51 }
52 const at = Date.parse(row.timestamp ?? '')
53 const content = row.message?.content
54 if (!Array.isArray(content) || Number.isNaN(at)) continue
55 for (const c of content as { type?: string; name?: string; input?: unknown }[]) {
56 const p = c.type === 'tool_use' && EDIT_TOOLS.includes(c.name ?? '') ? editPath(c.input) : null
57 if (p) out.push({ path: p, at })
58 }
59 }
60 return out
61}
62
63export type Row = { key: string; depth: number; name: string; path: string; isDir: boolean; isOpen: boolean; count: number; state: FileState }
64
65type Dir = { dirs: Map<string, Dir>; files: string[]; count: number }
66
67/**
68 * The tree as rows. A directory opens when it holds a red or green file (or the person
69 * opened it) unless the person shut it; a closed one shows its file count. Capped at `cap` rows.
70 */
71export function rows(m: MapState, cap = 400): { rows: Row[]; isCut: boolean } {
72 const inside = [...new Set([...m.files, ...Object.keys(m.edits).filter(p => !isOutside(p))])]
73 const root: Dir = { dirs: new Map(), files: [], count: 0 }
74 for (const f of inside) {
75 const parts = f.split('/')
76 let d = root
77 d.count++
78 for (const seg of parts.slice(0, -1)) {
79 let next = d.dirs.get(seg)
80 if (!next) d.dirs.set(seg, (next = { dirs: new Map(), files: [], count: 0 }))
81 next.count++
82 d = next
83 }
84 d.files.push(parts.at(-1)!)
85 }
86 const hot = new Set<string>()
87 for (const p of inside) {
88 if (fileState(m, p) === null) continue
89 const parts = p.split('/')
90 for (let i = 1; i < parts.length; i++) hot.add(parts.slice(0, i).join('/'))
91 }
92 const open = new Set(m.open)
93 const shut = new Set(m.shut)
94 const out: Row[] = []
95 let isCut = false
96 const walk = (d: Dir, prefix: string, depth: number) => {
97 for (const name of [...d.dirs.keys()].sort()) {
98 if (out.length >= cap) return void (isCut = true)
99 const path = prefix + name
100 const sub = d.dirs.get(name)!
101 const isOpen = !shut.has(path) && (hot.has(path) || open.has(path))
102 out.push({ key: `d:${path}`, depth, name, path, isDir: true, isOpen, count: sub.count, state: null })
103 if (isOpen) walk(sub, `${path}/`, depth + 1)
104 }
105 for (const name of d.files.sort()) {
106 if (out.length >= cap) return void (isCut = true)
107 const path = prefix + name
108 out.push({ key: `f:${path}`, depth, name, path, isDir: false, isOpen: false, count: 0, state: fileState(m, path) })
109 }
110 }
111 walk(root, '', 0)
112 return { rows: out, isCut }
113}
114
115// ββ copied from cheap-worker/hooks/worker.ts (plugins can't import each other) ββ
116export const projectDir = (home: string, cwd: string) => `${home}/.claude/projects/${cwd.replace(/[/.]/g, '-')}`
117export const META_FORMAT = '#{pane_id}\t#{@cw}\t#{@cw_model}\t#{@cw_mode}\t#{@cw_start}\t#{@cw_cwd}\t#{@cw_task}'
118export function parseMeta(stdout: string): { pane: string; name: string; cwd: string }[] {
119 return stdout
120 .split('\n')
121 .map(l => l.split('\t'))
122 .filter(f => f[0]?.startsWith('%') && f[1])
123 .map(f => ({ pane: f[0]!, name: f[1]!, cwd: f[5] ?? '' }))
124}
125export const isWorking = (capture: string) => /esc to interrupt/.test(capture)
126types/index.d.ts 23 lines1export type AgentKind = 'main' | 'sub' | 'worker'
2/** An agent and the file it's editing right now (null: between files, or done). */
3export type MapAgent = { id: string; name: string; kind: AgentKind; file: string | null }
4export type Edit = { at: number; by: string[] }
5export type MapState = {
6 agents: MapAgent[]
7 /** path (relative to the session cwd, or absolute when outside it) β last edit */
8 edits: Record<string, Edit>
9 /** path β when the person marked it reviewed; green again only after a newer edit */
10 reviewed: Record<string, number>
11 /** the project's files (git ls-files), relative */
12 files: string[]
13 /** directories the person opened / closed by hand */
14 open: string[]
15 shut: string[]
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'agent-map': { map: MapState }
21 }
22}
23