SLOPSHOPPER

where-am-i

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

newbandguardcommandpromptmodel
v0.1.4no licenseupdated 2026-10-14aidan-nunn-sys/claude-config/mods/where-am-i
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-config

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.

New machine

git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh

Then:

  • Fill in any <SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).
  • optimaizr CLI (audited 0.11.0; re-audit before bumping), in ~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }
  • Restart Claude Code.

After changing settings

Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.

Layout

PathWhat
mods/Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder)
plugins/plugins.jsonMarketplaces and which marketplace plugins are enabled
skills/Personal skills, symlinked into ~/.claude/skills
settings/settings.json~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths
settings/mcp.jsonUser MCP servers, secrets blanked
settings/rulebook.jsonGeneric example rules (copied only when the machine has none; not captured)
docs/specs/Design specs

Mods

ModSourceLocal changes
hudownprompt hint, context weather bar, cache countdown, kaomoji mascot
rulebookownhard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule
xagentowndispatch/route tools for Codex, Copilot, Antigravity
handoffown/handoff [next] writes a handoff doc to ~/.claude/handoffs/
agent-radarhamzafer/claude-code-mods @ 87f108479bbf/radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents
mission-controlhamzafer/claude-code-mods @ 87f108479bbfshows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped
blast-radiushamzafer/claude-code-mods @ 87f108479bbf60 s auto-cancel
review-watchhamzafer/claude-code-mods @ 87f108479bbfnone
where-am-ihamzafer/claude-code-mods @ 87f108479bbfshows only "next"
secret-boxray-amjad/claude-code-mods @ 467b20300d8enone
task-boardMarcoCarnevali/claude-code-mods @ a3e528dd00ce/board (not /tasks), no auto-open, mid-turn + toggle

Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.

Not in this repo

  • ~/.claude/.credentials.json and MCP secrets.
  • Managed by other tools: omarchy skills (diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook.
  • Per-machine state: memory, history, sessions, plans.
  • .claude-plugin/types/ in each mod: SDK types Claude Code writes itself.
  • Hiding Claude Code's native subagent area: the mod API exposes no component for it (checked 2026-10-14; Spinner props are word, message, suffix, mode).
Source 2 files
hooks/register.tsx 150 lines
1// Where Am I: the next step above the prompt, plus /where for a full recap.
2// Local change: the band no longer shows goal, now or waiting on you.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { Recap } from '../types'
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)
13// True while the next-steps mod shows its list of next prompts: this band leaves out its own next meanwhile.
14const nextStepsActive = { plugin: 'next-steps', key: 'active' } as const
15
16export const register: Register = on => {
17  let prompt = ''
18  let log: string[] = []
19
20  on('session.start', async ($, e, next) => {
21    const r = await next(e)
22    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
23    return r
24  })
25
26  on('prompt.submit', async ($, e, next) => {
27    prompt = e.text.slice(0, 1500)
28    log = []
29    return next(e)
30  })
31
32  // Observe only: note the call for the recap, then let it run untouched.
33  // Only Claude's own calls count: one another mod makes in the background (`$.tool.call`,
34  // `$.mcp.call`) is raised by that plugin, and `next.origin` names it instead of the engine.
35  on('tool.call', async ($, e, next) => {
36    if (next.origin.plugin !== 'engine') return next(e)
37    const line = describe(e as unknown as Record<string, unknown>)
38    log = [...log, e.agentId ? `(agent) ${line}` : line].slice(-MAX_LOG)
39    return next(e)
40  })
41
42  on('turn.complete', async ($, e, next) => {
43    const r = await next(e)
44    if (!e.agentId) {
45      void summarize($, prompt, log, e.answer).catch(() => {}) // in the background, so the turn ends at once
46    }
47    return r
48  })
49
50  on('command.run', { command: 'where' }, async $ => ({ text: await longRecap($, prompt, log) }))
51
52  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
53    const rest = await next(e) // what other mods and Claude Code draw here stays
54    const r = await read($, recap)
55    if (e.props.hasSurvey || !r) return rest
56
57    const { value: hasNextSteps = false } = await $.state.get(nextStepsActive)
58    if (hasNextSteps || r.next === '') return rest
59
60    const { Box, Text } = $.ui.resolve(e)
61    return (
62      <Box flexDirection="column">
63        <Box paddingX={1}>
64          <Text wrap="truncate-end">
65            <Text color="cyan" bold>{'◆ next: '}</Text>
66            <Text>{clip(r.next)}</Text>
67          </Text>
68        </Box>
69        {rest}
70      </Box>
71    )
72  })
73}
74
75// A short label for one tool call: what a person would say Claude is doing.
76export function describe(e: Record<string, unknown>): string {
77  const tool = String(e.tool)
78  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
79  const file = (p: string) => p.split('/').slice(-2).join('/')
80  if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 60)}`
81  if (tool === 'Edit' || tool === 'Write') return `editing ${file(s('file_path'))}`
82  if (tool === 'Read') return `reading ${file(s('file_path'))}`
83  if (tool === 'Grep' || tool === 'Glob') return `searching for ${s('pattern').slice(0, 40)}`
84  if (tool === 'Agent') return `starting an agent: ${s('description')}`
85  if (tool === 'AskUserQuestion') return 'asking you a question'
86  if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ')}`
87  return `using ${tool}`
88}
89
90async function summarize($: EngineInterface, prompt: string, log: string[], answer: string) {
91  const before = await read($, recap)
92  const r = await $.model.complete({
93    model: MODEL,
94    maxTokens: 300,
95    system:
96      'You keep a one-glance recap of a coding session for someone with ADHD. Plain words, no em dashes, ' +
97      'each field at most 70 characters. Reply with JSON only: {"goal","now","waiting","next"}. ' +
98      '"goal": the overall aim of the session (keep the previous goal unless it clearly changed). ' +
99      '"now": what was just done. "waiting": what the assistant is waiting on from the person, or "". ' +
100      '"next": the next step.',
101    prompt: [
102      `Previous recap: ${before ? JSON.stringify(before) : 'none'}`,
103      `The person's latest message: ${prompt}`,
104      `Tools used this turn: ${log.join('; ') || 'none'}`,
105      `The assistant's reply: ${answer.slice(0, 2500)}`,
106    ].join('\n\n'),
107  })
108  if (!r.isAnswered) return
109  const parsed = parseRecap(r.text)
110  if (parsed) await update($, recap, () => parsed)
111}
112
113async function longRecap($: EngineInterface, prompt: string, log: string[]) {
114  const messages = (await $.session.messages()).slice(-12)
115  const r = await $.model.complete({
116    model: MODEL,
117    maxTokens: 500,
118    system:
119      'Write a recap of this coding session for someone who lost track. Plain words, no em dashes. ' +
120      'At most 6 short bullets: the goal, what is done, what is happening now, what is waiting on them, the next step.',
121    prompt: [
122      `<transcript>\n${messages.filter(m => m.text.trim() !== '').map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
123      `Latest message: ${prompt}`,
124      `Recent tool calls: ${log.join('; ') || 'none'}`,
125      'Write the recap of the transcript above now: the bullets only, not a reply to it.',
126    ].join('\n\n'),
127  })
128  return r.isAnswered ? r.text : 'Could not build a recap right now.'
129}
130
131// At most 70 characters, cut at a word: the model does not always keep to the limit.
132export function clip(text: string, max = 70) {
133  const first = text.split(/(?<=[.!?])\s/)[0] ?? text // one sentence
134  if (first.length <= max) return first.replace(/\.$/, '')
135  return `${first.slice(0, first.lastIndexOf(' ', max - 1))}…`
136}
137
138// The model's JSON, tolerating a code fence around it.
139export function parseRecap(text: string): Recap | null {
140  const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
141  try {
142    const o = JSON.parse(body) as Record<string, unknown>
143    const field = (k: string) => (typeof o[k] === 'string' ? clip((o[k] as string).replace(/\s*—\s*/g, ', ').trim()) : '')
144    if (!field('goal')) return null
145    return { goal: field('goal'), now: field('now'), waiting: field('waiting'), next: field('next') }
146  } catch {
147    return null
148  }
149}
150
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