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β¦

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:
/inbox (again to close it)./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.gcdTourney Β· attack alts), for example Needs your approval: Bash: npm run pr -- merge 12. One per change, never repeated.How it decides:
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.
hooks/register.tsx 437 lines1import 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}
437types/index.d.ts 26 lines1/**
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