A live recap above the prompt: goal, doing now, waiting on you, next. /where for a longer one.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 177 lines1// Where Am I: a live recap above the prompt (goal, now, waiting on you, next), plus /where.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Recap } from '../types'
6import { bandsFile, parseOrder, stackBands } from './bands'
7
8const MODEL = 'haiku'
9const MAX_LOG = 20
10
11// Held by the host, so the recap survives a hot reload of this file.
12const recap = atom({ plugin: 'where-am-i', key: 'recap' } as const, null as Recap | null)
13const live = atom({ plugin: 'where-am-i', key: 'live' } as const, '')
14// True while the next-steps mod shows its list of next prompts: this band leaves out its own next meanwhile.
15const nextStepsActive = { plugin: 'next-steps', key: 'active' } as const
16
17let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
18
19// The band order file, as the claude-code-mods checklist saves it; [] on any error.
20async function readBandOrder($: EngineInterface): Promise<string[]> {
21 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
22 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
23}
24
25// Forgets the cached band order, for tests.
26export function resetBandOrder() {
27 bandOrder = undefined
28}
29
30export const register: Register = on => {
31 let prompt = ''
32 let log: string[] = []
33
34 on('session.start', async ($, e, next) => {
35 const r = await next(e)
36 await $.command.register({ name: 'where', description: 'Recap the session so far in a few bullets' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
37 return r
38 })
39
40 on('prompt.submit', async ($, e, next) => {
41 prompt = e.text.slice(0, 1500)
42 log = []
43 await update($, live, () => 'reading your message')
44 return next(e)
45 })
46
47 // Observe only: note what is happening, then let the call run untouched.
48 // Only Claude's own calls count: one another mod makes in the background (`$.tool.call`,
49 // `$.mcp.call`) is raised by that plugin, and `next.origin` names it instead of the engine.
50 on('tool.call', async ($, e, next) => {
51 if (next.origin.plugin !== 'engine') return next(e)
52 const line = describe(e as unknown as Record<string, unknown>)
53 log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
54 if (!e.agentId) await update($, live, () => line)
55 const r = await next(e)
56 return r
57 })
58
59 on('turn.complete', async ($, e, next) => {
60 const r = await next(e)
61 if (!e.agentId) {
62 await update($, live, () => '')
63 void summarize($, prompt, log, e.answer).catch(() => {}) // in the background, so the turn ends at once
64 }
65 return r
66 })
67
68 on('command.run', { command: 'where' }, async $ => ({ text: await longRecap($, prompt, log) }))
69
70 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71 const rest = await next(e) // what other mods and Claude Code draw here stays
72 const r = await read($, recap)
73 if (e.props.hasSurvey || !r) return rest
74
75 const { Box, Text } = $.ui.resolve(e)
76 const now = clip((await read($, live)) || r.now)
77 const { value: hasNextSteps = false } = await $.state.get(nextStepsActive)
78 const nextStep = hasNextSteps ? '' : r.next
79
80 const mine = (
81 <Box flexDirection="column" paddingX={1}>
82 <Text wrap="truncate-end">
83 <Text color="cyan" bold>{'◆ Goal: '}</Text>
84 <Text>{clip(r.goal)}</Text>
85 </Text>
86 <Text wrap="truncate-end">
87 <Text dimColor>{' now: '}</Text>
88 <Text>{now}</Text>
89 {nextStep !== '' && <Text dimColor>{' · next: '}</Text>}
90 {nextStep !== '' && <Text>{clip(nextStep)}</Text>}
91 </Text>
92 {r.waiting !== '' && (
93 <Text color="yellow" wrap="truncate-end">{` waiting on you: ${clip(r.waiting)}`}</Text>
94 )}
95 </Box>
96 )
97 bandOrder ??= readBandOrder($).catch(() => [])
98 return stackBands(rest, 'where-am-i', mine, 'above', await bandOrder) // above what other mods draw, unless the band order says otherwise
99 })
100}
101
102// A short label for one tool call: what a person would say Claude is doing.
103export function describe(e: Record<string, unknown>): string {
104 const tool = String(e.tool)
105 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
106 const file = (p: string) => p.split('/').slice(-2).join('/')
107 if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 60)}`
108 if (tool === 'Edit' || tool === 'Write') return `editing ${file(s('file_path'))}`
109 if (tool === 'Read') return `reading ${file(s('file_path'))}`
110 if (tool === 'Grep' || tool === 'Glob') return `searching for ${s('pattern').slice(0, 40)}`
111 if (tool === 'Agent') return `starting an agent: ${s('description')}`
112 if (tool === 'AskUserQuestion') return 'asking you a question'
113 if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ')}`
114 return `using ${tool}`
115}
116
117async function summarize($: EngineInterface, prompt: string, log: string[], answer: string) {
118 const before = await read($, recap)
119 const r = await $.model.complete({
120 model: MODEL,
121 maxTokens: 300,
122 system:
123 'You keep a one-glance recap of a coding session for someone with ADHD. Plain words, no em dashes, ' +
124 'each field at most 70 characters. Reply with JSON only: {"goal","now","waiting","next"}. ' +
125 '"goal": the overall aim of the session (keep the previous goal unless it clearly changed). ' +
126 '"now": what was just done. "waiting": what the assistant is waiting on from the person, or "". ' +
127 '"next": the next step.',
128 prompt: [
129 `Previous recap: ${before ? JSON.stringify(before) : 'none'}`,
130 `The person's latest message: ${prompt}`,
131 `Tools used this turn: ${log.join('; ') || 'none'}`,
132 `The assistant's reply: ${answer.slice(0, 2500)}`,
133 ].join('\n\n'),
134 })
135 if (!r.isAnswered) return
136 const parsed = parseRecap(r.text)
137 if (parsed) await update($, recap, () => parsed)
138}
139
140async function longRecap($: EngineInterface, prompt: string, log: string[]) {
141 const messages = (await $.session.messages()).slice(-12)
142 const r = await $.model.complete({
143 model: MODEL,
144 maxTokens: 500,
145 system:
146 'Write a recap of this coding session for someone who lost track. Plain words, no em dashes. ' +
147 'At most 6 short bullets: the goal, what is done, what is happening now, what is waiting on them, the next step.',
148 prompt: [
149 `<transcript>\n${messages.filter(m => m.text.trim() !== '').map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
150 `Latest message: ${prompt}`,
151 `Recent tool calls: ${log.join('; ') || 'none'}`,
152 'Write the recap of the transcript above now: the bullets only, not a reply to it.',
153 ].join('\n\n'),
154 })
155 return r.isAnswered ? r.text : 'Could not build a recap right now.'
156}
157
158// At most 70 characters, cut at a word: the model does not always keep to the limit.
159export function clip(text: string, max = 70) {
160 const first = text.split(/(?<=[.!?])\s/)[0] ?? text // one sentence
161 if (first.length <= max) return first.replace(/\.$/, '')
162 return `${first.slice(0, first.lastIndexOf(' ', max - 1))}…`
163}
164
165// The model's JSON, tolerating a code fence around it.
166export function parseRecap(text: string): Recap | null {
167 const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
168 try {
169 const o = JSON.parse(body) as Record<string, unknown>
170 const field = (k: string) => (typeof o[k] === 'string' ? clip((o[k] as string).replace(/\s*—\s*/g, ', ').trim()) : '')
171 if (!field('goal')) return null
172 return { goal: field('goal'), now: field('now'), waiting: field('waiting'), next: field('next') }
173 } catch {
174 return null
175 }
176}
177hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73types/index.d.ts 10 lines1export type Recap = { goal: string; now: string; waiting: string; next: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'where-am-i': { recap: Recap | null; live: string }
6 // Owned by the next-steps mod, read here. Kept the same as its own contract.
7 'next-steps': { steps: { turnId: string; items: string[] } | null; active: boolean }
8 }
9}
10