SLOPSHOPPER

error-inbox

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…

newpaneguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-13samuelbfernandes/moddeck/mods/error-inbox
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · error-inbox
│ ┃ Errors ✕ › fix the failing auth test and add an audit log call │ ┃ 1 error [ Clear ] │ ┃ failed · Bash · 08:53:20 Ask Claude ⏺ Read(src/auth.ts) │ ┃ bun test ⎿ Read 6 lines │ ┃ src/auth.test.ts: ✓ refreshes expired ⏺ Update(src/auth.ts) │ ┃ token ✓ rejects a bad signature ✓ issues a new ⎿ Added 2 lines, removed 1 line │ ┃ token ✗ revokes on logout error: expected ⏺ Bash(bun test) │ ┃ 401, got 200 3 pass 1 fail ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /error-inbox │ ⎿ error-inbox: error-inbox · on · 1 kept │ ⎿ error-inbox: Last: failed · Bash · 08:53:20 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Errors
1 error [ Clear ] failed · Bash · 08:53:20 Ask Claude bun test src/auth.test.ts: ✓ refreshes expired token ✓ rejects a bad signature ✓ issues a new token ✗ revokes on logout error: expected 401, got 200 3 pass 1 fail
README

error-inbox

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.

What it does

  • Watches every tool call after it ran and keeps the last 30 that:
  • failed: the tool answered an error (a non-zero exit, a file not found, an edit that did not match);
  • denied: the error says the person or a permission rule turned the call down;
  • refused: a hook or plugin refused the call.
  • For each it keeps the tool name, the command or file path only (never file contents or other inputs), the error as the model read it cut to 600 characters, and the time. Obvious secrets (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.
  • Memory only: nothing is written to disk, and the list is gone when the session ends.
  • Never blocks or changes a call: the model gets every result exactly as it came back.

Use

/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

Turn off

/error-inbox off, or /plugin disable error-inbox.

State for the moddeck side bar

{ 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.

Limits

  • "Denied" is told apart from "failed" by the words of the error, so a reworded message lands under "failed".
  • A permission prompt the person answers "no" to is kept only if the engine reports it as the call's error result, which is how it reaches the model; this was checked against the API's types and a mocked engine, not in a live session.
  • The pane draws about 9,000 characters, newest first; older entries are counted, not drawn.
  • Secret scrubbing catches the common shapes only; it is a safety net, not a guarantee.
  • Tested with a mocked engine (claude plugin test), on the terminal and desktop element tables.

Credit

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.

Source 3 files
hooks/register.tsx 181 lines
1// 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}
181
hooks/lib.ts 72 lines
1// 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}
72
types/index.d.ts 28 lines
1/** 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