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

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.
Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
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:
/tui fullscreen so panes sit beside the transcript.skills 4 · agents 2 · CLAUDE.md · plugins …)./agents-side./session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.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.| Plugin | Command | What it does |
|---|---|---|
| agents-side | /agents-side | Open or close the pane |
| agents-side | /agents-side clear | Drop finished agents |
| agents-side | /agents-side close | Close the pane |
| context-board | /context-board | Open or close the pane (the bar stays) |
| context-board | /context-board refresh | Recount every category with the token-count API |
| context-board | /context-board pane | Force-open the pane |
| context-board | /context-board close | Close the pane |
| session-map | /session-map | Open or close the file pane |
| session-map | /session-map clear | Drop the file list |
| session-map | /session-map close | Close the pane |
| blast-radius | /blast-radius | Re-open the hold pane if a command is waiting |
| blast-radius | /blast-radius close | Cancel 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.
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:
sec-default in prependPlugins.$(…) and scripts that call rm can still get past it).$.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.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.
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-radiushooks/register.ts 125 lines1import { 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}
125hooks/draw.ts 75 lines1import { 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}
75hooks/format.ts 14 lines1export 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}
14hooks/model.ts 78 lines1export 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