SLOPSHOPPER

session-map

Live pane of files Claude read, edited, or wrote this session, grouped by monorepo app.

newpaneguardcommandstatus
v0.1.0no licenseupdated 2026-10-06yogeshvar/mod-claude/plugins/session-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-map
│ ┃ Map ✕ › fix the failing auth test and add an audit log call │ ┃ Session map │ ┃ 3 files this session ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ src (3) ⏺ Update(src/auth.ts) │ ┃ W src/audit.ts ⎿ Added 2 lines, removed 1 line │ ┃ E src/auth.ts ⏺ Bash(bun test) │ ┃ W src/cache.ts ⎿ 3 pass, 1 fail │ ┃ │ ┃ c: clear x: close ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-map │ ⎿ session-map: Opened the session map. Dock it beside the transcri │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-map: map: 3 files · src 3

Draws

Pane · Map
Session map 3 files this session src (3) W src/audit.ts E src/auth.ts W src/cache.ts c: clear x: close
README

Claude Code mods

A small CLI cockpit for teams that already have agents, hooks, and instructions, and that still use Claude Code in the terminal (including Bedrock). Mods draw live UI and can hold a tool call. They do not replace those files.

  1. context-board — compact context card above the prompt, plus a one-line kit strip of what this session actually loaded
  2. agents-side — a pane of the main loop and every subagent it spawns
  3. session-map — files Claude read, edited, or wrote this session, grouped by monorepo app
  4. blast-radius — holds risky Bash until you Proceed or Cancel

Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Install

claude plugin marketplace add yogeshvar/mod-claude
claude plugin install agents-side@mod-claude
claude plugin install context-board@mod-claude
claude plugin install session-map@mod-claude
claude plugin install blast-radius@mod-claude

Or clone and load for one session:

git clone https://github.com/yogeshvar/mod-claude.git
cd mod-claude
./start-claude

Then:

  1. Stretch the terminal to at least 110 columns (144 if you want unasked panes to auto-dock). Run /tui fullscreen so panes sit beside the transcript.
  2. The context card sits above the prompt: used vs window, compact threshold, a stacked color bar, a small legend, and a kit line (skills 4 · agents 2 · CLAUDE.md · plugins …).
  3. Ask Claude to spawn a couple of subagents. If the agents pane did not auto-open, run /agents-side.
  4. Run /session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.
  5. A pnpm migrate, git reset --hard, git push --force, DROP TABLE, rm -rf, or rm .env opens Blast Radius. 1 proceeds, 2 cancels. This is a safety net, not a permission system — keep hard denies in managed settings.

Commands

PluginCommandWhat it does
agents-side/agents-sideOpen or close the pane
agents-side/agents-side clearDrop finished agents
agents-side/agents-side closeClose the pane
context-board/context-boardOpen or close the pane (the bar stays)
context-board/context-board refreshRecount every category with the token-count API
context-board/context-board paneForce-open the pane
context-board/context-board closeClose the pane
session-map/session-mapOpen or close the file pane
session-map/session-map clearDrop the file list
session-map/session-map closeClose the pane
blast-radius/blast-radiusRe-open the hold pane if a command is waiting
blast-radius/blast-radius closeCancel the held command

On the agents pane, keys 1–9 select a row (focus the pane first: click it, or ctrl+x tab). c clears finished agents, x closes it.

On the context pane: r refresh, x close.

On the session map: c clear, x close.

On Blast Radius: 1 Proceed, 2 Cancel.

openOnStart (agents pane; session-map, off by default) and openPaneOnStart (context pane, off by default) are plugin options under /config.

Bedrock and team machines

Claude Code on Amazon Bedrock / an API key does not load the built-in sec-default guard unless you push managed settings. Without that, a user-installed mod can override deny rules.

For the team:

  1. Allow this marketplace (and block unknown ones) in managed settings.
  2. Put sec-default in prependPlugins.
  3. Keep production denies in permission rules. Blast Radius only holds commands whose text looks risky ($(…) and scripts that call rm can still get past it).
  4. On Bedrock, $.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.

What each one shows

context-board calls $.session.usage({ breakdown: "summary" }) after each main-loop turn (local estimates, no extra API). /context-board refresh uses breakdown: "full". The kit strip reads enabled plugins from settings, CLAUDE.md / AGENTS.md from the instruction walk, skill and agent counts from usage, and the last Skill tool name.

agents-side watches agent.spawn, tool.call, turn.start, turn.complete, and session.measure. It never blocks those events.

session-map observes Read, Edit, Write, and Glob after they run. It never blocks.

blast-radius classifies Bash, dry-runs git status / git clean -n when it can, and holds the call until Proceed or Cancel.

Check the plugins

claude plugin validate .
claude plugin validate ./plugins/agents-side
claude plugin validate ./plugins/context-board
claude plugin validate ./plugins/session-map
claude plugin validate ./plugins/blast-radius
claude plugin test ./plugins/agents-side
claude plugin test ./plugins/context-board
claude plugin test ./plugins/session-map
claude plugin test ./plugins/blast-radius
Source 4 files
hooks/register.ts 125 lines
1import { drawMap } from './draw.ts'
2import { relPath } from './format.ts'
3import { PANE, groupOf, putHit, statusLine, type FileHit, type FileKind } from './model.ts'
4
5let files: FileHit[] = []
6let root = ''
7
8function pin($: { ui: { status: (text: string | undefined) => void; invalidate: (event: 'ui.render') => void } }) {
9  $.ui.status(statusLine(files))
10  $.ui.invalidate('ui.render')
11}
12
13function paneArgs() {
14  return { id: PANE, title: 'Map', columns: 44, rows: 22 }
15}
16
17async function isOpen($: { ui: { panes: () => Promise<Array<{ id: string; isPlaced: boolean }>> } }) {
18  const panes = await $.ui.panes()
19  return panes.some(pane => pane.id === PANE && pane.isPlaced)
20}
21
22function kindOf(tool: string): FileKind | null {
23  if (tool === 'Read') return 'read'
24  if (tool === 'Edit') return 'edit'
25  if (tool === 'Write') return 'write'
26  if (tool === 'Glob') return 'glob'
27  return null
28}
29
30function pathOf(e: Record<string, unknown>, tool: string): string | null {
31  if (typeof e.file_path === 'string' && e.file_path) return e.file_path
32  if (tool === 'Glob' && typeof e.pattern === 'string' && e.pattern) return e.pattern
33  return null
34}
35
36function note(path: string, kind: FileKind) {
37  const rel = kind === 'glob' ? path : relPath(root, path)
38  files = putHit(files, {
39    path,
40    rel,
41    group: kind === 'glob' ? 'globs' : groupOf(rel),
42    kind,
43    hits: 1,
44  })
45}
46
47export function register(
48  on: (
49    event: string,
50    matcher?: unknown,
51    hook?: (...args: never[]) => unknown,
52  ) => void,
53  options: Readonly<Record<string, string | number | boolean | readonly string[]>>,
54) {
55  const openOnStart = options.openOnStart === true
56
57  on('session.start', async ($, e, next) => {
58    const result = await next(e)
59    files = []
60    root = e.cwd
61    await $.command.register({
62      name: 'session-map',
63      description: 'Toggle the pane of files Claude has seen this session',
64      argumentHint: '[clear|close]',
65      immediate: true,
66    })
67    if (e.isInteractive && openOnStart) await $.ui.open(paneArgs())
68    pin($)
69    return result
70  })
71
72  on('tool.call', async ($, e, next) => {
73    const result = await next(e)
74    const tool = String(e.tool ?? '')
75    const kind = kindOf(tool)
76    if (kind) {
77      const path = pathOf(e as Record<string, unknown>, tool)
78      if (path) note(path, kind)
79      pin($)
80    }
81    return result
82  }).catch(($, e, next) => next(e))
83
84  on('command.run', { command: 'session-map' }, async ($, e) => {
85    const arg = e.args.trim()
86    if (arg === 'close') {
87      await $.ui.close({ id: PANE })
88      return { text: 'Closed the session map.' }
89    }
90    if (arg === 'clear') {
91      files = []
92      pin($)
93      return { text: 'Cleared the session map.' }
94    }
95    if (await isOpen($)) {
96      await $.ui.close({ id: PANE })
97      return { text: 'Closed the session map.' }
98    }
99    await $.ui.open(paneArgs())
100    return {
101      text: 'Opened the session map. Dock it beside the transcript with /tui fullscreen.',
102    }
103  })
104
105  on('ui.render', { component: 'Pane' }, ($, e, next) => {
106    if (e.requestId !== PANE) return next(e)
107    const { Box, Text, Button } = $.ui.resolve(e)
108    return drawMap(Box, Text, Button, files, { bodyColumns: e.props.bodyColumns, showClose: true })
109  })
110
111  on('ui.press', async ($, e, next) => {
112    if (e.plugin !== 'session-map') return next(e)
113    if (e.element === 'clear') {
114      files = []
115      pin($)
116      return {}
117    }
118    if (e.element === 'close') {
119      await $.ui.close({ id: PANE })
120      return {}
121    }
122    return next(e)
123  })
124}
125
hooks/draw.ts 75 lines
1import { clip } from './format.ts'
2import { groupsOf, type FileHit } from './model.ts'
3
4export type Node = unknown
5export type El = (props: Record<string, unknown>) => Node
6
7function mark(kind: FileHit['kind']): string {
8  if (kind === 'write') return 'W'
9  if (kind === 'edit') return 'E'
10  if (kind === 'glob') return 'G'
11  return 'R'
12}
13
14export function drawMap(
15  Box: El,
16  Text: El,
17  Button: El,
18  files: FileHit[],
19  args: { bodyColumns: number; showClose: boolean },
20): Node {
21  const width = Math.max(24, args.bodyColumns - 2)
22  const groups = groupsOf(files)
23  const kids: Node[] = [
24    Text({ bold: true, children: 'Session map' }),
25    Text({
26      dimColor: true,
27      children: files.length === 0 ? 'no files yet this session' : `${files.length} files this session`,
28    }),
29  ]
30
31  let shown = 0
32  const cap = 40
33  for (const group of groups) {
34    kids.push(Text({ children: ' ' }))
35    kids.push(
36      Text({
37        bold: true,
38        color: '#63b3ed',
39        children: `${group.name} (${group.files.length})`,
40      }),
41    )
42    for (const file of group.files) {
43      if (shown >= cap) break
44      shown += 1
45      const label = file.rel === '.' ? file.path : file.rel
46      kids.push(
47        Text({
48          children: `  ${mark(file.kind)}  ${clip(label, width - 6)}`,
49        }),
50      )
51    }
52    if (shown >= cap) break
53  }
54
55  if (files.length > shown) {
56    kids.push(Text({ dimColor: true, children: `  +${files.length - shown} more` }))
57  }
58
59  if (args.showClose) {
60    kids.push(Text({ children: ' ' }))
61    kids.push(
62      Box({
63        flexDirection: 'row',
64        columnGap: 2,
65        children: [
66          Button({ key: 'clear', label: 'clear', hotkey: 'c', plain: true, onPress: () => undefined }),
67          Button({ key: 'close', label: 'close', hotkey: 'x', plain: true, onPress: () => undefined }),
68        ],
69      }),
70    )
71  }
72
73  return Box({ flexDirection: 'column', paddingX: 1, paddingY: 0, children: kids })
74}
75
hooks/format.ts 14 lines
1export function clip(text: string, max: number): string {
2  const t = text.replace(/\s+/g, ' ').trim()
3  if (t.length <= max) return t
4  if (max <= 1) return '…'
5  return `${t.slice(0, max - 1)}…`
6}
7
8export function relPath(root: string, abs: string): string {
9  const r = root.replace(/\/+$/, '')
10  if (abs === r) return '.'
11  if (abs.startsWith(`${r}/`)) return abs.slice(r.length + 1)
12  return abs
13}
14
hooks/model.ts 78 lines
1export const PANE = 'session-map'
2
3export type FileKind = 'read' | 'edit' | 'write' | 'glob'
4
5export type FileHit = {
6  path: string
7  rel: string
8  group: string
9  kind: FileKind
10  hits: number
11}
12
13const RANK: Record<FileKind, number> = { glob: 0, read: 1, edit: 2, write: 3 }
14
15export function groupOf(rel: string): string {
16  const parts = rel.split('/').filter(part => part && part !== '.')
17  if (parts.length === 0) return 'root'
18  if ((parts[0] === 'apps' || parts[0] === 'packages') && parts[1]) {
19    return `${parts[0]}/${parts[1]}`
20  }
21  if (parts.length === 1) return 'root'
22  return parts[0] ?? 'root'
23}
24
25export function stronger(a: FileKind, b: FileKind): FileKind {
26  return RANK[a] >= RANK[b] ? a : b
27}
28
29export function putHit(files: FileHit[], next: FileHit): FileHit[] {
30  const i = files.findIndex(row => row.path === next.path)
31  if (i === -1) return [...files, next]
32  const prev = files[i]
33  if (!prev) return [...files, next]
34  const copy = files.slice()
35  copy[i] = {
36    ...prev,
37    kind: stronger(prev.kind, next.kind),
38    hits: prev.hits + next.hits,
39    rel: next.rel || prev.rel,
40    group: next.group || prev.group,
41  }
42  return copy
43}
44
45export type Group = { name: string; files: FileHit[] }
46
47export function groupsOf(files: FileHit[]): Group[] {
48  const names: string[] = []
49  const by = new Map<string, FileHit[]>()
50  for (const file of files) {
51    const list = by.get(file.group)
52    if (list) list.push(file)
53    else {
54      names.push(file.group)
55      by.set(file.group, [file])
56    }
57  }
58  names.sort((a, b) => {
59    if (a === 'root') return 1
60    if (b === 'root') return -1
61    return a.localeCompare(b)
62  })
63  return names.map(name => ({
64    name,
65    files: (by.get(name) ?? []).slice().sort((a, b) => a.rel.localeCompare(b.rel)),
66  }))
67}
68
69export function statusLine(files: FileHit[]): string {
70  if (files.length === 0) return 'map: no files yet'
71  const groups = groupsOf(files)
72  const top = groups
73    .slice(0, 3)
74    .map(group => `${group.name} ${group.files.length}`)
75    .join(' · ')
76  return `map: ${files.length} files · ${top}`
77}
78