SLOPSHOPPER

agent-map

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

newpanespinnerguardcommandprocess
v0.1.0no licenseupdated 2026-10-10ianwilson97/claude-mods/agent-map
A shopper browsing a rack in a slop shop
Preview Β· a replayed session in a sandbox
claude Β· ~/work/app Β· agent-map
β”‚ ┃ Agent map βœ• β€Ί fix the failing auth test and add an audit log call β”‚ ┃ πŸ”¨ 0 editing βœ“ 3 ready for review mark al β”‚ ┃ ⏺ Read(src/auth.ts) β”‚ ┃ app/ ⎿ Read 6 lines β”‚ ┃ β–Ύ src/ ⏺ Update(src/auth.ts) β”‚ ┃ audit.ts βœ“ Γ— ⎿ Added 2 lines, removed 1 line β”‚ ┃ auth.ts βœ“ Γ— ⏺ Bash(bun test) β”‚ ┃ cache.ts βœ“ Γ— ⎿ 3 pass, 1 fail β”‚ ┃ README.md β”‚ ┃ package.json ● Done. refresh now rejects expired claims and logs an audit event. β”‚ ┃ src β”‚ ✻ Worked for 42s Β· done 4:20 PM β”‚ β”‚ β€Ί /map β”‚ ⎿ agent-map: Agent map open. β”‚ β”‚ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── β€Ί ⟨Claude Code's own drawing⟩ βœ“ 3

Draws

Pane Β· Agent map
πŸ”¨ 0 editing βœ“ 3 ready for review mark all reviewed app/ β–Ύ src/ audit.ts βœ“ Γ— auth.ts βœ“ Γ— cache.ts βœ“ Γ— README.md package.json src
Prompt hint
⟨Claude Code's own drawing⟩ βœ“ 3
README

ianwilson-mods

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.

ModWhat 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-barContext-window breakdown above the prompt.
usage-meter5-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-bandFor the driver-seat workflow; skip them elsewhere.

Install

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

cheap-worker: two switches

Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):

OptionValues
provideropenrouter (default)Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins.
bedrockClaude 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.
launchpanes (default)All workers tiled in tmux session cw: tmux new -A -s cw.
tabsEach 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.

Reaching a worker: crew

The 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+.

First run on a new machine: checks

  1. /plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads.
  2. /worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …).
  3. Turn on team, launch two workers, then from /workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.
Source 3 files
hooks/register.tsx 241 lines
1import { 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}
241
hooks/map.ts 126 lines
1import 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)
126
types/index.d.ts 23 lines
1export 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