Keeps the last 30 failed or refused tool calls of the session in a pane, each with a button that drafts a prompt asking Claude to explain it. Watches only…

Install: /plugin install error-inbox --marketplace samuelbfernandes/moddeck
One place for everything that went wrong in a session's tool calls, with a button to ask Claude about any of them.
ghp_..., sk-..., AKIA..., Bearer x, password=..., --token x, a password in a URL) are replaced by *** first./error-inbox opens a pane, newest first, with Clear. Each entry has an Ask Claude button that puts a draft in the prompt box ("Please explain this error from a Bash call and suggest a fix (do not change anything yet)", with the input and the error). The draft is never sent: you edit it or press Enter. If the box already holds text, the draft is added after it. Where the box cannot take it (a dialog is open), the draft is copied to the clipboard instead and a toast says so./error-inbox open the pane and print a one-line summary /error-inbox status the summary only /error-inbox clear empty the list /error-inbox off stop keeping errors (remembered across sessions); /error-inbox on resumes
/error-inbox off, or /plugin disable error-inbox.
{ plugin: 'error-inbox', key: 'entries' } (the last 30, oldest first: tool, kind, input, error, at) and { plugin: 'error-inbox', key: 'isOff' } (boolean). $.store holds only off.
claude plugin test), on the terminal and desktop element tables.Adapted from error-inbox in awesome-claude-code-mods (MIT, © 2026 Yash Thakker and contributors). Rewritten for moddeck: TypeScript in the house style, failed, denied and refused told apart, the command or path kept beside the error, secrets scrubbed, the list in $.state, an "Ask Claude" button that drafts a prompt without sending it, an on/off switch, and tests. The upstream licence is in LICENSE.
hooks/register.tsx 181 lines1// error-inbox: keeps the last 30 tool calls of the session that failed (the tool answered an error), were denied (the
2// person or a permission rule said no) or were refused (a hook turned them down), with the tool, the command or file
3// path (never file contents) and the error cut to 600 characters, secrets replaced by ***. `/error-inbox` shows them in
4// a pane, newest first, with Clear, and per entry an "Ask Claude" button that drafts a prompt (not sent) asking Claude
5// to explain that error. Memory only.
6// FAIL OPEN: it never blocks or changes a call; if recording fails, the result goes back to the model untouched.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, RenderSurface, Register } from 'claude-code'
9
10import type { ErrorInboxEntry } from '../types'
11import { clockTime, cut, draftFor, failureOf, inputSummary, scrub } from './lib'
12
13type Dollar = EngineInterface
14
15const COMMAND = 'error-inbox'
16const PANE = 'error-inbox'
17const KEEP = 30
18/** About how many characters the pane draws, newest entries first. */
19const BUDGET = 9000
20
21const entries = atom({ plugin: 'error-inbox', key: 'entries' } as const, [])
22const isOff = atom({ plugin: 'error-inbox', key: 'isOff' } as const, false)
23
24let counter = 0
25
26async function setSwitch($: Dollar, off: boolean): Promise<void> {
27 if (off) await $.store.set('off', true)
28 else await $.store.delete('off')
29 await update($, isOff, () => off)
30}
31
32async function isSwitchedOff($: Dollar): Promise<boolean> {
33 try {
34 return await read($, isOff)
35 } catch {
36 return false
37 }
38}
39
40async function status($: Dollar): Promise<string> {
41 const list = await read($, entries)
42 const last = list[list.length - 1]
43 const lines = [`error-inbox · ${(await isSwitchedOff($)) ? 'off' : 'on'} · ${list.length} kept`]
44 if (last !== undefined) lines.push(`Last: ${last.kind} · ${last.tool} · ${clockTime(last.at)}`)
45
46 return lines.join('\n')
47}
48
49async function run($: Dollar, args: string): Promise<string> {
50 const word = args.trim().toLowerCase()
51 if (word === 'status') return status($)
52 if (word === 'clear') {
53 await update($, entries, () => [])
54
55 return 'error-inbox · cleared'
56 }
57 if (word === 'on' || word === 'off') {
58 try {
59 await setSwitch($, word === 'off')
60 } catch {
61 return 'error-inbox · that did not work, nothing changed'
62 }
63
64 return word === 'on' ? 'error-inbox · on' : 'error-inbox · off · /error-inbox on keeps errors again'
65 }
66 if (word !== '') return `error-inbox · unknown option “${word.slice(0, 30)}”\nUse /error-inbox, or /error-inbox on, off, clear or status.`
67 await $.ui.open({ id: PANE, title: 'Errors', columns: 80 })
68
69 return status($)
70}
71
72async function record($: Dollar, tool: string, input: Record<string, unknown>, ran: Parameters<typeof failureOf>[0]): Promise<void> {
73 const failure = failureOf(ran)
74 if (failure === undefined || (await isSwitchedOff($))) return
75 const at = await $.clock.now()
76 counter += 1
77 const entry: ErrorInboxEntry = {
78 id: `${at}-${counter}`,
79 tool: cut(tool, 80),
80 kind: failure.kind,
81 input: inputSummary(input),
82 error: cut(scrub(failure.text).trim(), 600),
83 at,
84 }
85 await update($, entries, list => [...list, entry].slice(-KEEP))
86}
87
88/** Puts the draft in the prompt box without sending it; where the box cannot take it, on the clipboard instead. */
89async function ask($: Dollar, entry: ErrorInboxEntry, surface: RenderSurface): Promise<void> {
90 const draft = draftFor(entry)
91 let isFilled = false
92 try {
93 const box = await $.prompt.read()
94 const isEmpty = box.text.trim() === ''
95 isFilled = (await $.prompt.fill({ text: isEmpty ? draft : `\n\n${draft}`, mode: isEmpty ? 'replace' : 'append' })).isFilled
96 } catch {
97 isFilled = false
98 }
99 if (isFilled) {
100 $.ui.toast('error-inbox: a draft is in the prompt box; edit it or press Enter to send')
101
102 return
103 }
104 const copied = await $.ui.copy({ text: draft, surface }).catch(() => ({ isCopied: false as const }))
105 $.ui.toast(copied.isCopied ? 'error-inbox: the prompt box was busy, so the draft is on the clipboard' : 'error-inbox: could not reach the prompt box or the clipboard')
106}
107
108const KIND_COLOR: Record<ErrorInboxEntry['kind'], string> = { failed: 'red', denied: 'yellow', refused: 'yellow' }
109
110export const register: Register = on => {
111 on('session.start', async ($, e, next) => {
112 try {
113 const stored = (await $.store.get('off')) === true
114 await update($, isOff, () => stored)
115 } catch {
116 // reads as on
117 }
118 try {
119 await $.command.register({ name: COMMAND, description: 'Show the failed or refused tool calls of this session', argumentHint: '[on|off|clear|status]' })
120 } catch {
121 // fail-open: the session goes on without the command
122 }
123
124 return next(e)
125 })
126
127 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, e.args) })).catch(() => ({
128 text: 'error-inbox · could not read its own state',
129 }))
130
131 // Watches only: the call runs as the model made it and its result goes back unchanged.
132 on('tool.call', async ($, e, next) => {
133 const ran = await next(e)
134 try {
135 await record($, String(e.tool), e as unknown as Record<string, unknown>, ran)
136 } catch {
137 // an inbox problem never reaches the model
138 }
139
140 return ran
141 }).catch(($, e, next) => next(e))
142
143 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
144 const { Box, Button, Text } = $.ui.resolve(e)
145 const list = await read($, entries)
146 const off = await isSwitchedOff($)
147 let budget = BUDGET
148 const shown: ErrorInboxEntry[] = []
149 for (const entry of [...list].reverse()) {
150 budget -= entry.error.length + entry.input.length + 80
151 if (budget < 0) break
152 shown.push(entry)
153 }
154
155 return (
156 <Box flexDirection="column">
157 <Box>
158 <Text bold>
159 {list.length} error{list.length === 1 ? '' : 's'}{off ? ' · off' : ''}{' '}
160 </Text>
161 <Button key="clear" label="Clear" onPress={async () => void (await update($, entries, () => []))} />
162 </Box>
163 {list.length === 0 && <Text dimColor>No failed or refused tool calls yet.</Text>}
164 {shown.map(entry => (
165 <Box flexDirection="column">
166 <Box>
167 <Text wrap="truncate-end">
168 <Text color={KIND_COLOR[entry.kind]}>{entry.kind}</Text> · {entry.tool} · {clockTime(entry.at)}{' '}
169 </Text>
170 <Button key={`ask:${entry.id}`} label="Ask Claude" plain onPress={() => ask($, entry, e.surface)} />
171 </Box>
172 {entry.input !== '' && <Text wrap="truncate-end"> {entry.input}</Text>}
173 <Text dimColor> {entry.error}</Text>
174 </Box>
175 ))}
176 {shown.length < list.length && <Text dimColor>+{list.length - shown.length} older not shown</Text>}
177 </Box>
178 )
179 })
180}
181hooks/lib.ts 72 lines1// error-inbox: pure helpers (no engine access): what a failed call was, and secret scrubbing.
2import type { ErrorInboxKind } from '../types'
3
4// ---- secrets ------------------------------------------------------------------------------------------------------
5// The same function is copied from test-ledger (mods cannot import each other); keep the two in step.
6
7const SECRET_PATTERNS: [RegExp, string][] = [
8 [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{10,}/g, '***'],
9 [/\bgithub_pat_[A-Za-z0-9_]{10,}/g, '***'],
10 [/\bsk-[A-Za-z0-9_-]{8,}/g, '***'],
11 [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, '***'],
12 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '***'],
13 [/\bglpat-[A-Za-z0-9_-]{10,}/g, '***'],
14 [/\bAIza[0-9A-Za-z_-]{30,}/g, '***'],
15 [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '***'],
16 [/\b(Bearer|Basic|Token)(\s+)[A-Za-z0-9._~+/=-]{6,}/gi, '$1$2***'],
17 [/(--?(?:token|password|passwd|pass|secret|api[-_]?key|access[-_]?key|auth|private[-_]?key)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi, '$1***'],
18 [/((?:password|passwd|pwd|secret|token|api[-_]?key|apikey|access[-_]?key|auth[-_]?key|client[-_]?secret)["']?\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&;,'"]+)/gi, '$1***'],
19 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s/@]+@/gi, '$1***@'],
20]
21
22/** Replaces the obvious secrets (API tokens, keys, Bearer values, password=..., --token x, URL passwords) with ***. */
23export function scrub(text: string): string {
24 return SECRET_PATTERNS.reduce((out, [pattern, to]) => out.replace(pattern, to), text)
25}
26
27/** Drops control characters (keeps newline and tab) and cuts to `limit` characters with an ellipsis. */
28export function cut(text: string, limit: number): string {
29 const clean = text.replace(/\r\n/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
30
31 return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean
32}
33
34// ---- what the call was ---------------------------------------------------------------------------------------------
35
36/** The command or the file path of a call: never its contents. '' when it has neither. */
37export function inputSummary(input: Record<string, unknown>): string {
38 for (const key of ['command', 'file_path', 'notebook_path', 'path']) {
39 const value = input[key]
40 if (typeof value === 'string' && value.trim() !== '') return cut(scrub(value).replace(/\s+/g, ' ').trim(), 200)
41 }
42
43 return ''
44}
45
46/** Words that mark a call the person (or a permission rule) turned down, rather than one that ran and failed. */
47const DENIED = /doesn't want to proceed|tool use was rejected|user (?:rejected|denied|declined)|permission to use .{0,300}? (?:was|has been) denied/i
48
49/** Whether a finished call is worth keeping, and as what. */
50export function failureOf(ran: { deny?: string; isError?: true; text?: string; result?: unknown }): { kind: ErrorInboxKind; text: string } | undefined {
51 if (ran.deny !== undefined) return { kind: 'refused', text: ran.deny }
52 if (ran.isError !== true) return undefined
53 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
54
55 return { kind: DENIED.test(text) ? 'denied' : 'failed', text: text || 'The tool reported an error and no text.' }
56}
57
58/** The draft the "Ask Claude" button puts in the prompt box. */
59export function draftFor(entry: { tool: string; input: string; error: string }): string {
60 const lines = [`Please explain this error from a ${entry.tool} call and suggest a fix (do not change anything yet).`]
61 if (entry.input !== '') lines.push(`Input: ${entry.input}`)
62 lines.push('Error:', entry.error)
63
64 return lines.join('\n')
65}
66
67export const clockTime = (ms: number): string => {
68 const d = new Date(ms)
69
70 return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
71}
72types/index.d.ts 28 lines1/** refused: a hook or plugin turned the call down; denied: the person or a permission rule did; failed: it ran and errored. */
2export type ErrorInboxKind = 'refused' | 'denied' | 'failed'
3
4/** One failed or refused tool call. */
5export type ErrorInboxEntry = {
6 /** Unique within the session. */
7 id: string
8 tool: string
9 kind: ErrorInboxKind
10 /** The command or file path only, secrets replaced by ***, at most 200 characters; '' for other tools. */
11 input: string
12 /** The error as the model read it, secrets replaced by ***, at most 600 characters. */
13 error: string
14 /** When it came back, ms since the epoch. */
15 at: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'error-inbox': {
21 /** The last 30, oldest first. Memory only: gone when the session ends. */
22 entries: ErrorInboxEntry[]
23 /** The persisted on/off switch (`$.store` 'off'), mirrored so the moddeck side bar can read it. */
24 isOff: boolean
25 }
26 }
27}
28