SLOPSHOPPER

tool-coach

Stops the model from repeating a tool call that just failed with the same input, until a file or command has changed something.

newguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-coach
› 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 › /tool-coach ⎿ tool-coach: on ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tool-coach

A Read of a missing file fails, and the model asks for the very same Read again, and again: each round costs a request and brings the same error. This mod stops the model from repeating a tool call that just failed with the same input. The call is not run again until a file or a command has changed something, and the model reads the error it already got instead.

What it does

  1. The mod hooks every tool call: the built-in tools, MCP tools and the calls of subagents. Bash is left out, because a failed command often runs again for good reasons, such as a test after a fix.
  2. A call whose result is an error is kept with its error. This covers errors the tool itself reports (File does not exist, String to replace not found, an MCP error) and input the engine refuses (InputValidationError).
  3. The same call again, with the same tool and the same input, is not run. The model reads this instead of a result:

this exact Read call failed a moment ago, and no file or command has changed anything since, so it would fail the same way. Its error was: File does not exist. Note: your current working directory is /w. Read the error, change the input, and call again.

The error is the one the model read, cut at 300 characters. Two calls are the same when their input holds the same values, whatever the order of the keys. A description field only labels a call, so it is not compared. The main loop and each subagent keep their own failed calls.

  1. A call with any other input runs as usual.
  2. A successful Edit, Write, NotebookEdit or Bash call drops every kept call, because it may have changed what the failed call needed: a file now exists, a command started a server. So does each new turn, because you may have changed something by hand, and so does /tool-coach on or off.
  3. The same moment writes one line, so you see which call was refused. With the sidebar open, the line is an entry in its stream, the tool name red and the rest faint; else it goes to the transcript:

tool-coach: Read call repeated after it failed, not run

In the live check the model read a missing file, then asked for the same Read again. The second call did not run, and the model reported the error it had already got. After a Write created the file, the same Read ran and returned the text. A failed Bash command ran again as asked.

Command

/tool-coach on or off /tool-coach on | off on by default

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install tool-coach@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=tool-coach}, turn.start, tool.call ❯ ./register.ts calls: $.command.register, $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand), $.ui.log (via toPerson)

Reach L0, draws and remembers.

  1. Reads: the tool name, the input and the result of each tool call
  2. Runs: nothing
  3. Sends: a deny text to the model when a failed call is repeated unchanged, and one line to the sidebar or the transcript; nothing leaves the machine
  4. Persists: in $.store, the on/off setting; the failed calls live in memory until a change or the next turn
  5. Hostile input: a call's input and error are only compared and quoted back to the model, never run or opened

Limits

  • The mod cannot read a tool's input schema, so it does not check an input before the first call. The engine checks the schema itself and answers InputValidationError; the mod stops the repeat.
  • A change the mod does not see, such as a file another program writes, does not drop the kept calls. The next turn does.
  • A failed call can succeed later with the same input for a reason that is not a file or a command, such as an MCP server that reconnected. The model then needs another input, or the next turn.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 89 lines
1import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
2import { callKey, CHANGING, denyText, errorOf, sectionKey, stoppedLines, UNWATCHED, type Failures, type Line } from './coach.ts'
3
4const ENABLED_KEY = 'enabled'
5
6const USAGE = 'expects nothing (the status), on or off'
7
8type State = { failures: Failures; enabled: boolean }
9
10/**
11 * The line the person reads: an entry in the shared sidebar's stream while it is open, else the
12 * transcript line. The model's deny is another channel.
13 */
14async function toPerson($: EngineInterface, key: string, lines: Line[], line: string): Promise<void> {
15  try {
16    const taken = await $.sidebar.set({ consumer: 'tool-coach', key: sectionKey(key), title: 'repeat stopped', lines, until: 'stream' })
17    if (taken) return
18  } catch {
19    // The sidebar mod is not installed.
20  }
21  $.ui.log(line)
22}
23
24/** Whether a result is a success of a call that changes files or runs commands. */
25const changedSomething = (tool: string, r: ToolCallResult): boolean => CHANGING.has(tool) && r.deny === undefined && r.isError !== true
26
27/**
28 * Runs one call. A watched call that failed before with the same input, while nothing has changed since,
29 * is refused with the error it got; a watched call that fails is kept; a success that changes something
30 * drops every record.
31 */
32async function coach($: EngineInterface, state: State, e: ToolCallInput, next: (e: ToolCallInput) => Promise<ToolCallResult>): Promise<ToolCallResult> {
33  const watched = !UNWATCHED.has(e.tool)
34  const key = callKey(e as unknown as Record<string, unknown>)
35  const failed = watched ? state.failures.get(key) : undefined
36  // Only a call the mod would refuse or record reads the setting; every other call runs as it is.
37  if (failed !== undefined && (await readSettings($, state))) {
38    await toPerson($, e.tool, stoppedLines(e.tool), stoppedLines(e.tool)[0]?.text ?? '')
39    return { deny: denyText(e.tool, failed) }
40  }
41  const r = await next(e)
42  if (changedSomething(e.tool, r)) state.failures.clear()
43  else if (watched && r.isError === true && (await readSettings($, state))) state.failures.set(key, errorOf(r.text))
44  return r
45}
46
47/**
48 * Reads the on/off setting from the store, which every window shares, so a change made in another
49 * window applies here at the next hook that acts on it. Answers whether the mod is on.
50 */
51async function readSettings($: EngineInterface, state: State): Promise<boolean> {
52  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
53  return state.enabled
54}
55
56async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
57  const word = args.trim()
58  await readSettings($, state)
59  if (word === 'on' || word === 'off') {
60    await $.store.set(ENABLED_KEY, word === 'on')
61    state.enabled = word === 'on'
62    state.failures.clear()
63    return word === 'on' ? 'on: a failed call is not run again unchanged' : 'off: every call runs'
64  }
65  return word === '' ? (state.enabled ? 'on' : 'off') : USAGE
66}
67
68export const register: Register = on => {
69  const state: State = { failures: new Map(), enabled: true }
70
71  on('session.start', async ($, e, next) => {
72    const r = await next(e)
73    await $.command.register({ name: 'tool-coach', description: 'Refuse a failed tool call repeated unchanged: status, on, off (tool-coach)', argumentHint: '[on | off]' })
74    await readSettings($, state)
75    return r
76  })
77
78  // The engine prints the plugin name in front of command text, so the texts do not repeat it.
79  on('command.run', { command: 'tool-coach' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
80
81  // The person's own prompt may follow a change the mod cannot see (a file fixed by hand).
82  on('turn.start', async (_, e, next) => {
83    state.failures.clear()
84    return next(e)
85  })
86
87  on('tool.call', async ($, e, next) => coach($, state, e, next))
88}
89
hooks/coach.ts 66 lines
1/** The failed tool calls a loop may not repeat as they were, and the texts the model and the person read. */
2
3/**
4 * Bash is not watched: a command that failed runs again for good reasons (a test after a fix, a server
5 * that was not up yet), and the person chose to leave it free.
6 */
7export const UNWATCHED = new Set(['Bash'])
8
9/**
10 * A successful call of one of these changed a file or ran a command, so a call that failed before it may
11 * not fail again: every record is dropped.
12 */
13export const CHANGING = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
14
15/** The keys of the call envelope that are not the tool's input, and `description`, which only labels it. */
16const NOT_INPUT = new Set(['tool', 'tool_use_id', 'agentId', 'consent', 'description'])
17
18/** How much of the error the model is shown again. */
19const MAX_ERROR_CHARS = 300
20
21/** The failed calls, keyed by loop and input, each with the error it got. */
22export type Failures = Map<string, string>
23
24/** A value with its object keys sorted, so two inputs that differ only in key order are one call. */
25function sorted(value: unknown): unknown {
26  if (Array.isArray(value)) return value.map(sorted)
27  if (value === null || typeof value !== 'object') return value
28  const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
29  return Object.fromEntries(entries.map(([k, v]) => [k, sorted(v)]))
30}
31
32/** The key of one call: its loop (the main loop or a subagent), its tool, and its input. */
33export function callKey(call: Record<string, unknown>): string {
34  const input = Object.fromEntries(Object.entries(call).filter(([k]) => !NOT_INPUT.has(k)))
35  return JSON.stringify([call.agentId ?? 'main', call.tool, sorted(input)])
36}
37
38/** The error as the model read it, without the engine's tags, cut to a length a deny can carry. */
39export function errorOf(text: string | undefined): string {
40  const bare = (text ?? '').replace(/<\/?tool_use_error>/g, '').trim()
41  const one = bare === '' ? 'no error text' : bare
42  return one.length > MAX_ERROR_CHARS ? `${one.slice(0, MAX_ERROR_CHARS)}…` : one
43}
44
45/** What the model reads instead of the repeated call's result. The engine names the mod in front of it. */
46export function denyText(tool: string, error: string): string {
47  return `this exact ${tool} call failed a moment ago, and no file or command has changed anything since, so it would fail the same way. Its error was:\n${error}\nRead the error, change the input, and call again.`
48}
49
50/** How the sidebar colours a line or a part of one. */
51type Tone = 'ok' | 'warn' | 'error' | 'dim'
52export type Part = { text: string; kind?: Tone }
53/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
54export type Line = { text: string; kind?: Tone; parts?: Part[] }
55
56/** The person's line: the tool in red, the rest faint. The same text is the transcript line. */
57export function stoppedLines(tool: string): Line[] {
58  const parts: Part[] = [{ text: tool, kind: 'error' }, { text: ' call repeated after it failed, not run', kind: 'dim' }]
59  return [{ text: parts.map(p => p.text).join(''), parts }]
60}
61
62/** A sidebar section key: the subject cut to what the sidebar takes. */
63export function sectionKey(text: string): string {
64  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
65}
66