SLOPSHOPPER

where-am-i

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

newbandguardcommandpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · where-am-i
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /where ⎿ where-am-i: OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in 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.

Source 3 files
hooks/register.tsx 177 lines
1// 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}
177
hooks/bands.ts 73 lines
1// 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}
73
types/index.d.ts 10 lines
1export 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