SLOPSHOPPER

inbox

Which of your chats, across every project on this computer, are waiting on you and why: πŸ“₯ in the footer, /inbox for the list, a notification when one starts…

newpaneguardcommandtoasttimer
v0.1.0no licenseupdated 2026-10-25eshin087/claude-code-mods/inbox
A shopper browsing a rack in a slop shop
Preview Β· a replayed session in a sandbox
claude Β· ~/work/app Β· inbox
β”‚ ┃ Inbox βœ• β€Ί fix the failing auth test and add an audit log call β”‚ ┃ Inbox [ Close ] β”‚ ┃ ⏺ Read(src/auth.ts) β”‚ ┃ Your turn ⎿ Read 6 lines β”‚ ┃ β–Έ app Done 0s Done. ⏺ 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 β”‚ β”‚ β€Ί /inbox β”‚ ⎿ inbox: Inbox opened. β”‚ β”‚ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── β€Ί ? for shortcuts

Draws

Pane Β· Inbox
Inbox [ Close ] Your turn β–Έ app Done 0s Done. I made refresh
README

Inbox

What it does: with several chats open at once (two or three in the same project, others elsewhere), it tells you which ones are waiting on you and why: a permission prompt that's up, a question Claude asked, or a reply that finished and that you haven't answered yet. It counts every chat on this computer, in every project.

Where you see it:

  • Nothing in the footer: open the list with /inbox (again to close it).
  • The list (/inbox): every chat that's waiting, in groups: `` Inbox Close Needs you mods Β· inbox Approval 10m wants to run Bash: npm test gcdAtlas Β· footer colors Question 3m Which color? Your turn gcdAtlas Β· docs Done 20m Wrote the docs. β–Έ gcdTourney Β· attack alts Done 0s Merged. 1 chat working `` "Needs you" (approvals and questions) comes first, the longest wait at the top; then "Your turn" (finished replies); then a dim count of chats still working. β–Έ marks the chat you're in. Each row is the project, the chat's title (or its first prompt's first line), what it's waiting for, how long, and why. The rows are for reading: switch chats in the sidebar. The list refreshes every 5 seconds while it's open.
  • A notification when a chat starts waiting for approval or asks a question, and when a reply that ran 2 minutes or longer finishes. Titled with the project and the chat (gcdTourney Β· attack alts), for example Needs your approval: Bash: npm run pr -- merge 12. One per change, never repeated.

How it decides:

  • Working: from your prompt until the reply ends.
  • Approval: a permission prompt is up (the engine's permission request, or its "needs your permission" notice). It clears as soon as that tool call goes ahead or is refused; another call finishing beside it doesn't clear it.
  • Question: Claude called AskUserQuestion, or an MCP server asked for input. It clears once you answer.
  • Done: the reply finished; the reason is its first line. It stays until your next prompt. A reply you stopped yourself counts as nothing waiting.
  • Gone quiet: a chat that closes says so and leaves the list. One that crashed or was killed without saying so drops off once its record is 12 hours old.

Data saved: each chat keeps one small file, ~/.claude/mods-data/inbox/<session>.json (or under CLAUDE_CONFIG_DIR when that's set): its project, title, state, reason and times. A closed chat's file stays, marked idle; mods can't delete files, so the folder grows by one tiny file per chat.

Cost: none. No model calls. Each chat reads the folder every 10 seconds (every 5 while the list is open) and writes its own file only when its state changes.

Source 2 files
hooks/register.tsx 437 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import type { InboxRecord, InboxState } from '../types'
4
5// Which chats are waiting on you, across every chat and project on this
6// computer. Each chat keeps one small file, ~/.claude/mods-data/inbox/<session>.json,
7// saying where it stands (a permission prompt up, a question asked, a reply
8// finished and unanswered, working, idle) and why. Every chat reads the whole
9// folder, so /inbox in any chat lists what waits in all of them, and a
10// notification goes out once when a chat starts waiting.
11
12const PANE = 'inbox'
13// A chat that closed without saying so must not haunt the list forever.
14const STALE_MS = 12 * 3_600_000
15// A reply this long is one you probably walked away from: its end is worth a notification.
16const LONG_TURN_MS = 2 * 60_000
17// The pane, while open, re-reads the folder this often.
18const PANE_REFRESH_MS = 5_000
19const TITLE_MAX = 60
20const REASON_MAX = 140
21
22const ACCENT = '#22d3ee'
23const AMBER = '#facc15'
24const GREEN = '#4ade80'
25const GREY = '#9ca3af'
26
27const STATES: readonly InboxState[] = ['approval', 'question', 'done', 'working', 'idle']
28const LABEL: Record<InboxState, string> = { approval: 'Approval', question: 'Question', done: 'Done', working: 'Working', idle: 'Idle' }
29const COLOR: Record<InboxState, string> = { approval: AMBER, question: ACCENT, done: GREEN, working: GREY, idle: GREY }
30
31// This chat's own record: the freshest copy, written to its file on every change.
32let me: InboxRecord | null = null
33// Every chat's record from the folder, this one's replaced by `me`. Render reads only this.
34let records: InboxRecord[] = []
35let paneTimer: Timer | null = null
36let isPaneOpen = false
37
38// The tool calls under way, by id, so a permission prompt can be tied to its call
39// and cleared when that call (and not another running beside it) goes ahead.
40const running = new Map<string, string>()
41let approvalFor: { id?: string; tool?: string } | null = null
42let questionFor: string | null = null
43
44const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
45// The first line of a text, its markdown marks taken off: a title or a reason.
46const firstLine = (text: string, max: number) => {
47  const line = (text.trim().split(/\r?\n/)[0] ?? '').replace(/^[#>*\-\s]+/, '').replace(/\*\*|__|`/g, '').trim()
48  return cut(line, max)
49}
50
51// 45s, 12m, 1h 05m: how long a chat has waited.
52const waited = (ms: number) => {
53  const s = Math.max(0, Math.round(ms / 1000))
54  if (s < 60) return `${s}s`
55  const m = Math.floor(s / 60)
56  if (m < 60) return `${m}m`
57  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
58}
59// 3m 12s: how long a reply took, for its notification.
60const took = (ms: number) => {
61  const s = Math.max(0, Math.round(ms / 1000))
62  const m = Math.floor(s / 60)
63  return m < 60 ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
64}
65
66// Same as the timings mod: a worktree's chats belong to the repository it was made from.
67const projectOf = (dir: string) => {
68  const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
69  return main.split(/[\\/]/).filter(Boolean).pop() ?? main
70}
71
72const folderOf = async ($: EngineInterface) => {
73  // CLAUDE_CONFIG_DIR (set when Claude's folder lives on another drive) wins over ~/.claude
74  const dir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
75  return `${dir}/mods-data/inbox`
76}
77
78// What a tool call is, in a few words: "Bash: npm run pr -- merge", "Edit: register.tsx".
79const describe = (tool: string, input: unknown) => {
80  const args = input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
81  const text = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
82  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
83  if (mcp) return `${mcp[2]} (${mcp[1]})`
84  if (text('command')) return `${tool}: ${firstLine(text('command'), 80)}`
85  const file = text('file_path') || text('notebook_path') || text('path')
86  if (file) return `${tool}: ${file.split(/[\\/]/).pop()}`
87  if (text('url')) return `${tool}: ${text('url').replace(/^https?:\/\//, '').split('/')[0]}`
88  if (tool === 'ExitPlanMode') return 'its plan'
89  return tool
90}
91const verbOf = (tool: string) => (/^(Bash|PowerShell)$/.test(tool) ? 'run' : tool === 'ExitPlanMode' ? 'go ahead with' : 'use')
92
93// The first question an AskUserQuestion call asks.
94const questionOf = (input: unknown) => {
95  const qs = input && typeof input === 'object' ? (input as { questions?: unknown }).questions : undefined
96  const first = Array.isArray(qs) ? (qs[0] as { question?: unknown } | undefined) : undefined
97  return typeof first?.question === 'string' ? firstLine(first.question, REASON_MAX) : 'Claude asked you something'
98}
99
100const isRecord = (r: unknown): r is InboxRecord => {
101  if (!r || typeof r !== 'object') return false
102  const x = r as Record<string, unknown>
103  return (
104    typeof x.sessionId === 'string' &&
105    typeof x.project === 'string' &&
106    typeof x.title === 'string' &&
107    typeof x.reason === 'string' &&
108    STATES.includes(x.state as InboxState) &&
109    typeof x.since === 'number' &&
110    typeof x.updatedAt === 'number'
111  )
112}
113
114// The chats worth showing: idle ones never; anything unwritten for 12 hours is a
115// chat that closed without saying so.
116const visible = (list: readonly InboxRecord[], now: number) => list.filter(r => r.state !== 'idle' && now - r.updatedAt <= STALE_MS)
117
118// This chat's record, from memory, else its file (a resumed chat, a reloaded mod), else new.
119// After a /clear the session id changes: that is a new chat with a record of its own.
120const mine = async ($: EngineInterface) => {
121  const sessionId = await $.session.id()
122  if (me && me.sessionId === sessionId) return me
123  const now = await $.clock.now()
124  let saved: InboxRecord | null = null
125  try {
126    const parsed: unknown = JSON.parse(await $.fs.read(`${await folderOf($)}/${sessionId}.json`))
127    saved = isRecord(parsed) ? parsed : null
128  } catch {
129    saved = null
130  }
131  me = saved ?? { sessionId, project: projectOf(await $.session.cwd()), title: '', state: 'idle', reason: '', since: now, updatedAt: now }
132  return me
133}
134
135const save = async ($: EngineInterface, r: InboxRecord) => {
136  me = r
137  records = [...records.filter(x => x.sessionId !== r.sessionId), r]
138  try {
139    const folder = await folderOf($)
140    // Whatever is newest by the time the write goes out: two changes close together
141    // must not leave the older one in the file.
142    const latest = me && me.sessionId === r.sessionId ? me : r
143    await $.fs.write(`${folder}/${r.sessionId}.json`, JSON.stringify(latest))
144  } catch {
145    // A folder we can't write to: this chat just stays out of the other chats' lists.
146  }
147  $.ui.invalidate('ui.render')
148}
149
150const notify = async ($: EngineInterface, r: InboxRecord, text: string) => {
151  try {
152    await $.ui.notify(text, { title: r.title ? `${r.project} Β· ${r.title}` : r.project })
153  } catch {
154    // A hook above refused it: /inbox still says so.
155  }
156}
157
158// Moves this chat to a state: `from`, when given, the states it may move from.
159// Only a real change of state is written, and only that change raises its
160// notification, so nothing is ever announced twice. The check and the change
161// sit with no wait between them, so two events arriving together can't both pass.
162const moveTo = async ($: EngineInterface, state: InboxState, reason: string, opts: { notice?: string; from?: readonly InboxState[] } = {}) => {
163  const now = await $.clock.now()
164  const r = await mine($)
165  if (r.state === state || (opts.from && !opts.from.includes(r.state))) return
166  const next: InboxRecord = { ...r, state, reason: cut(reason, REASON_MAX), since: now, updatedAt: now }
167  me = next
168  await save($, next)
169  if (opts.notice) await notify($, next, opts.notice)
170}
171
172const setTitle = async ($: EngineInterface, title: string) => {
173  const now = await $.clock.now()
174  const r = await mine($)
175  if (!title || r.title === title) return
176  await save($, { ...r, title, updatedAt: now })
177}
178
179// Every record in the folder. A file half written by another chat, or one this
180// mod didn't write, is skipped this round and read again on the next.
181const loadAll = async ($: EngineInterface) => {
182  const folder = await folderOf($)
183  const list: InboxRecord[] = []
184  for (const entry of await $.fs.list(folder).catch(() => [])) {
185    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
186    try {
187      const parsed: unknown = JSON.parse(await $.fs.read(`${folder}/${entry.name}`))
188      if (isRecord(parsed)) list.push(parsed)
189    } catch {
190      // Half written or not ours: left out.
191    }
192  }
193  records = me ? [...list.filter(r => r.sessionId !== me!.sessionId), me] : list
194}
195
196// Re-reads the folder; redraws while the pane shows it.
197const refresh = async ($: EngineInterface) => {
198  await loadAll($)
199  if (isPaneOpen) $.ui.invalidate('ui.render')
200}
201
202const togglePane = async ($: EngineInterface) => {
203  if ((await $.ui.panes()).some(p => p.id === PANE)) {
204    await $.ui.close({ id: PANE })
205    // A plugin's own close does not reach its own ui.close hook: stop the clock here.
206    isPaneOpen = false
207    paneTimer?.cancel()
208    paneTimer = null
209    return 'Inbox closed.'
210  }
211  await refresh($)
212  const opened = await $.ui.open({ id: PANE, title: 'Inbox' })
213  if (!opened.isPlaced) {
214    $.ui.toast(`The inbox could not open (${opened.reason}).`)
215    return `Could not open the inbox (${opened.reason}).`
216  }
217  isPaneOpen = true
218  paneTimer?.cancel()
219  paneTimer = $.clock.every(PANE_REFRESH_MS, () => void refresh($).catch(() => undefined))
220  $.ui.invalidate('ui.render')
221  return 'Inbox opened.'
222}
223
224// After a tool call ends (it ran, or was refused): a permission prompt or a
225// question that was about this call is over, and the reply goes on.
226const afterCall = async ($: EngineInterface, id: string, tool: string) => {
227  if (questionFor === id) {
228    questionFor = null
229    await moveTo($, 'working', '', { from: ['question'] })
230  }
231  const a = approvalFor
232  if (a && (a.id === id || (a.id === undefined && (a.tool === undefined || a.tool === tool)))) {
233    approvalFor = null
234    await moveTo($, 'working', '', { from: ['approval'] })
235  }
236}
237
238// A prompt already up (an approval or a question) is the one the person sees: a
239// second one beside it changes nothing until that one is answered.
240const NOT_WAITING: readonly InboxState[] = ['working', 'done', 'idle']
241
242const startApproval = async ($: EngineInterface, tool: string, input: unknown, reason?: string) => {
243  if (!NOT_WAITING.includes((await mine($)).state)) return
244  // The call it's about: the newest under way of that tool (the prompt names none by id).
245  const ids = [...running.entries()].filter(([, t]) => t === tool || tool === '').map(([id]) => id)
246  approvalFor = { id: ids.at(-1), tool: tool || undefined }
247  const what = describe(tool, input)
248  await moveTo($, 'approval', reason ?? `wants to ${verbOf(tool)} ${what}`, { notice: `Needs your approval: ${reason ?? what}`, from: NOT_WAITING })
249}
250
251const startQuestion = ($: EngineInterface, text: string) => moveTo($, 'question', text, { notice: `Asks you: ${text}`, from: NOT_WAITING })
252
253export const register: Register = on => {
254  on('session.start', async ($, e, next) => {
255    await $.command.register({ name: 'inbox', description: 'Which of your chats, in every project, are waiting on you and why' })
256    // A fresh process has no prompt up and no reply under way, whatever the file last said.
257    me = null
258    const r = await mine($)
259    if (r.state !== 'idle') await save($, { ...r, state: 'idle', reason: '', since: await $.clock.now(), updatedAt: await $.clock.now() })
260    await refresh($)
261    return next(e)
262  })
263
264  // The chat's own title, where the engine passes one (a renamed or titled chat).
265  on('classic.SessionStart', async ($, e, next) => {
266    const done = await next(e)
267    if (e.session_title) await setTitle($, cut(e.session_title.trim(), TITLE_MAX))
268    return done
269  })
270  on('classic.UserPromptSubmit', async ($, e, next) => {
271    const done = await next(e)
272    if (e.session_title) await setTitle($, cut(e.session_title.trim(), TITLE_MAX))
273    return done
274  })
275
276  on('command.run', { command: 'inbox' }, async $ => {
277    return { text: await togglePane($) }
278  })
279
280  on('ui.close', { id: PANE }, async ($, e, next) => {
281    const done = await next(e)
282    isPaneOpen = false
283    paneTimer?.cancel()
284    paneTimer = null
285    return done
286  })
287
288  // Your prompt starts a reply: whatever waited on you is answered.
289  on('turn.start', async ($, e, next) => {
290    if (e.agentId !== undefined) return next(e)
291    running.clear()
292    approvalFor = null
293    questionFor = null
294    const r = await mine($)
295    if (!r.title && e.text.trim()) await setTitle($, firstLine(e.text, TITLE_MAX))
296    await moveTo($, 'working', '')
297    return next(e)
298  })
299
300  // The main reply finished: your turn, until your next prompt. A stopped reply
301  // means you're there already. A long one gets a notification.
302  on('turn.complete', async ($, e, next) => {
303    const done = await next(e)
304    if (e.agentId !== undefined) return done
305    running.clear()
306    approvalFor = null
307    questionFor = null
308    if (e.isAborted) {
309      await moveTo($, 'idle', '')
310      return done
311    }
312    const reason = firstLine(e.answer, REASON_MAX) || (e.reason === 'answer' ? 'Finished' : 'Stopped on an error')
313    await moveTo($, 'done', reason, { notice: e.durationMs >= LONG_TURN_MS ? `Done after ${took(e.durationMs)}: ${reason}` : undefined })
314    return done
315  })
316
317  // Each tool call: AskUserQuestion is a question until it's answered; any call a
318  // permission prompt was about is over once the call goes ahead or is refused.
319  on('tool.call', async ($, e, next) => {
320    const id = e.tool_use_id
321    const tool = String(e.tool)
322    running.set(id, tool)
323    if (e.tool === 'AskUserQuestion' && e.agentId === undefined) {
324      questionFor = id
325      await startQuestion($, questionOf(e))
326    }
327    try {
328      return await next(e)
329    } finally {
330      running.delete(id)
331      await afterCall($, id, tool).catch(() => undefined)
332    }
333  })
334
335  // A permission prompt is about to show (unless a settings hook answered it).
336  on('classic.PermissionRequest', async ($, e, next) => {
337    const done = await next(e)
338    if (done.decision || done.block) return done
339    if (e.tool_name === 'AskUserQuestion') await startQuestion($, questionOf(e.tool_input))
340    else await startApproval($, e.tool_name, e.tool_input)
341    return done
342  })
343
344  // The engine's own "needs your attention" notices: a backup for the prompt above,
345  // and an MCP server's form. A plugin's own notification (ours too) is left alone.
346  on('classic.Notification', async ($, e, next) => {
347    const done = await next(e)
348    if (e.notification_type === 'permission_prompt') await startApproval($, '', undefined, firstLine(e.message, REASON_MAX) || 'needs your permission')
349    else if (e.notification_type === 'elicitation_dialog') await startQuestion($, firstLine(e.message, REASON_MAX) || 'An MCP server asks you something')
350    return done
351  })
352
353  on('classic.Elicitation', async ($, e, next) => {
354    const done = await next(e)
355    if (!done.block) await startQuestion($, firstLine(e.message, REASON_MAX) || `${e.mcp_server_name} asks you something`)
356    return done
357  })
358  on('classic.ElicitationResult', async ($, e, next) => {
359    const done = await next(e)
360    await moveTo($, 'working', '', { from: ['question'] })
361    return done
362  })
363
364  // The chat is closing: it leaves the list (idle is never shown). The record
365  // stays as a small file; nothing in $.fs deletes one.
366  on('session.end', async ($, e, next) => {
367    try {
368      const r = await mine($)
369      const now = await $.clock.now()
370      if (r.sessionId === e.sessionId) await save($, { ...r, state: 'idle', reason: 'closed', since: now, updatedAt: now })
371    } catch {
372      // The session is going either way.
373    }
374    // After a /clear the chat goes on under a new id: a new record.
375    me = null
376    return next(e)
377  })
378
379  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
380    const { Box, Text, Button } = $.ui.resolve(e)
381    const now = await $.clock.now()
382    const shown = visible(records, now)
383    const oldestFirst = (a: InboxRecord, b: InboxRecord) => a.since - b.since
384    const needs = shown.filter(r => r.state === 'approval' || r.state === 'question').sort(oldestFirst)
385    const yours = shown.filter(r => r.state === 'done').sort(oldestFirst)
386    const working = shown.filter(r => r.state === 'working').length
387    const cols = e.props.bodyColumns
388    const nameWidth = Math.max(16, Math.min(40, Math.floor(cols * 0.35)))
389
390    // One chat: β–Έ for this one, project Β· title, the state, how long, why.
391    const row = (r: InboxRecord) => (
392      <Box key={`chat:${r.sessionId}`} flexDirection="row" gap={1}>
393        <Box width={1} flexShrink={0}>
394          <Text color={ACCENT}>{r.sessionId === me?.sessionId ? 'β–Έ' : ' '}</Text>
395        </Box>
396        <Box width={nameWidth} flexShrink={0}>
397          <Text wrap="truncate-end">{r.title ? `${r.project} Β· ${r.title}` : r.project}</Text>
398        </Box>
399        <Box width={8} flexShrink={0}>
400          <Text bold color={COLOR[r.state]}>
401            {LABEL[r.state]}
402          </Text>
403        </Box>
404        <Box width={7} flexShrink={0}>
405          <Text color={GREY}>{waited(now - r.since)}</Text>
406        </Box>
407        <Box flexGrow={1} flexShrink={1} minWidth={0}>
408          <Text wrap="truncate-end">{r.reason}</Text>
409        </Box>
410      </Box>
411    )
412    const group = (key: string, heading: string, list: InboxRecord[]) =>
413      list.length > 0 && (
414        <Box key={key} flexDirection="column">
415          <Text bold>{heading}</Text>
416          {list.map(row)}
417        </Box>
418      )
419
420    return (
421      <Box flexDirection="column" gap={1}>
422        <Box flexDirection="row" gap={1}>
423          <Text bold color={ACCENT}>
424            Inbox
425          </Text>
426          <Box flexGrow={1} />
427          <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
428        </Box>
429        {needs.length === 0 && yours.length === 0 && <Text color={GREY}>Nothing is waiting on you.</Text>}
430        {group('needs', 'Needs you', needs)}
431        {group('yours', 'Your turn', yours)}
432        {working > 0 && <Text color={GREY}>{`${working} ${working === 1 ? 'chat' : 'chats'} working`}</Text>}
433      </Box>
434    )
435  })
436}
437
types/index.d.ts 26 lines
1/**
2 * Where a chat stands with its person:
3 * - `approval`: a permission prompt is up.
4 * - `question`: Claude asked something (AskUserQuestion, an MCP server's form).
5 * - `done`: the reply finished and the person hasn't sent the next prompt.
6 * - `working`: a reply is under way.
7 * - `idle`: nothing to say (a fresh chat, a stopped reply, a closed chat).
8 */
9export type InboxState = 'approval' | 'question' | 'done' | 'working' | 'idle'
10
11/** One chat, as ~/.claude/mods-data/inbox/<sessionId>.json keeps it. */
12export type InboxRecord = {
13  sessionId: string
14  /** The project folder's name; a worktree counts as its main repository. */
15  project: string
16  /** The chat's title, or the first line of its first prompt; '' before either. */
17  title: string
18  state: InboxState
19  /** Why, in a few words: what it wants to run, what it asked, how its reply began. */
20  reason: string
21  /** When the state began (ms). */
22  since: number
23  /** When the file was last written (ms): a record left this long unwritten is a chat gone quiet. */
24  updatedAt: number
25}
26