SLOPSHOPPER

retry-map

Inspect repeated Bash tool failures without changing tool results or making model calls.

newpaneguardcommand
v1.0.0MITupdated 2026-10-23GadDev/claude-mods/mods/retry-map
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · retry-map
│ ┃ Retry Map ✕ › fix the failing auth test and add an audit log call │ ┃ RETRY MAP │ ┃ ⏺ Read(src/auth.ts) │ ┃ Inspect repetition before spending another ⎿ Read 6 lines │ ┃ attempt. No automatic blocking or model ⏺ Update(src/auth.ts) │ ┃ calls. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ File-tool edits are observed; shell and ⎿ 3 pass, 1 fail │ ┃ external edits may be invisible. Identical │ ┃ failures can still be legitimate. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ #1 · 1 occurrence(s) · Failure recorded ✻ Worked for 42s · done 4:20 PM │ ┃ main: bun test │ ┃ [ Inspect failure 1 ] › /retry-map │ ┃ │ ┃ [ Clear history ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Retry Map
RETRY MAP Inspect repetition before spending another attempt. No automatic blocking or model calls. File-tool edits are observed; shell and external edits may be invisible. Identical failures can still be legitimate. #1 · 1 occurrence(s) · Failure recorded main: bun test [ Inspect failure 1 ] [ Clear history ]
README

Retry Map

<img src="assets/icon.png" width="112" alt="Retry Map: a looping path with a breakout arrow" />

Experimental 0.1.0. Inspect repeated Bash tool failures before spending another attempt. The mod observes tool outcomes and leaves their original results and permission checks unchanged.

Install and use

/plugin marketplace add GadDev/claude-mods
/plugin install retry-map@gaddev-mods

Reload plugins or restart Claude Code. Run /retry-map to open the pane, /retry-map show for a text overview, and /retry-map clear to clear its history.

When the same agent receives the exact same error for the exact same Bash command three times, a local transcript note suggests inspecting Retry Map. The pane shows counts and status, with an Inspect failure button for the command and error sample. No prompt is sent to Claude, no model is called, and no tool is blocked or retried automatically. You decide whether to interrupt or redirect the work.

Grouping rules

  • Grouping uses exact command and full error fingerprints, separately for each agent. Different commands, errors, or agents create different groups. It does not infer that semantically similar failures are identical.
  • A successful, non-staged Edit, Write, or NotebookEdit begins a new comparison period. Shell and external edits are not detected. A command overlapping an observed edit is not comparable.
  • A later successful foreground Bash tool outcome closes that command's active group. This says the tool later succeeded; it does not prove tests passed or the underlying problem was fixed.
  • Refused tool calls are not execution failures. Background starts and interrupted successful-tool payloads do not close a failure group. Final outcomes of background processes are not tracked in this MVP.
  • At most 30 groups are retained in session memory. Displayed commands are capped at 16,000 characters, errors at 8,000. Error signatures use up to 64,000 complete characters; missing or larger evidence never forms an identical-failure group. Truncated displays are labeled, and the original tool result stays in Claude Code's ordinary history.
  • Clear, session end, resume, branch, or reload removes the history. Pending observations from a previous lifecycle are ignored.

Repeated failure can be legitimate: waiting for a service, testing a changed environment, or checking an external fix. The count is advisory, not proof of wasted tokens. There is no dollar or token-savings counter.

Permissions and privacy

The mod uses command.register and UI APIs. It watches Bash results and successful file-tool calls. It reads no filesystem paths or prompts, changes no results, and performs no subprocesses, network calls, model calls, persistent storage, or permission approvals. Commands and errors can contain sensitive data; the bounded samples stay in the session's local mod memory. The threshold note is a UI log line that Claude does not read as new context.

Observation failures use the engine's normal fallback so the original tool behavior can continue; no recovery path calls the underlying command a second time.

Development

npm run check covers core grouping rules, native tool result preservation, command inspection, and pane contracts on terminal, Desktop, and VS Code. CLI 2.1.287+ is the documented baseline; native checks use 2.1.308. A live application smoke test remains useful for actual layout and keyboard behavior.

Source 2 files
hooks/register.ts 172 lines
1import type { Register } from 'claude-code';
2import { History, LIMITS } from '../src/history.ts';
3
4const PANE = 'retry-map';
5
6export const register: Register = (on) => {
7  const history = new History();
8  let selected: number | undefined;
9  let queue = Promise.resolve();
10  let generation = 0;
11
12  // Observational failures leave Claude Code's original tool result intact.
13  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
14    const startedInGeneration = generation;
15    const revisionAtStart = history.editRevision;
16    const result = await next(e);
17    if (result.deny !== undefined || startedInGeneration !== generation) return result;
18    const work = queue.then(async () => {
19      if (startedInGeneration !== generation) return;
20      const agent = e.agentId ?? 'main';
21      if (result.isError) {
22        const error =
23          result.text ?? (typeof result.result === 'string' ? result.result : undefined);
24        // A command overlapping an observed edit is not comparable to earlier attempts.
25        const group = await history.failure(
26          e.command,
27          error,
28          agent,
29          Date.now(),
30          revisionAtStart === history.editRevision,
31        );
32        if (group.count === LIMITS.warningOccurrences)
33          $.ui.log(
34            'Retry Map: the same command/error occurred three times for this agent. Inspect /retry-map before another retry.',
35          );
36      } else if (!result.result.backgroundTaskId && !result.result.interrupted) {
37        await history.success(e.command, agent);
38      }
39      $.ui.invalidate('ui.render');
40    });
41    queue = work.catch(() => undefined);
42    await queue;
43    return result;
44  });
45
46  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
47    const startedInGeneration = generation;
48    const result = await next(e);
49    if (result.deny === undefined && !result.isError && startedInGeneration === generation) {
50      const staged =
51        result.result &&
52        typeof result.result === 'object' &&
53        'staged' in result.result &&
54        result.result.staged === true;
55      if (staged) return result;
56      const work = queue.then(() => {
57        if (startedInGeneration === generation) history.observedEdit();
58        $.ui.invalidate('ui.render');
59      });
60      queue = work.catch(() => undefined);
61      await queue;
62    }
63    return result;
64  });
65
66  on('session.start', async ($, e, next) => {
67    await $.command.register({
68      name: PANE,
69      description: 'Inspect repeated Bash tool failures',
70      argumentHint: '[show|clear]',
71    });
72    return next(e);
73  });
74  on('session.end', async (_$, e, next) => {
75    generation += 1;
76    const work = queue.then(() => {
77      history.clear();
78      selected = undefined;
79    });
80    queue = work.catch(() => undefined);
81    await queue;
82    return next(e);
83  });
84  on('command.run', { command: PANE }, async ($, e) => {
85    await queue;
86    if (e.args.trim() === 'clear') {
87      generation += 1;
88      history.clear();
89      selected = undefined;
90      $.ui.invalidate('ui.render');
91      return { text: 'Retry Map history cleared.' };
92    }
93    if (e.args.trim() === 'show') return { text: history.summary() };
94    if (e.args.trim()) return { text: 'Use /retry-map, /retry-map show, or /retry-map clear.' };
95    await $.ui.open({ id: PANE, title: 'Retry Map', focus: true, closeOnEscape: true });
96    return {};
97  }).catch(() => ({ text: 'Retry Map could not open. Tool execution was not changed.' }));
98  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
99    if (e.requestId !== PANE) return next(e);
100    const { Box, Text, Button, Code } = $.ui.resolve(e);
101    const detail = history.groups.find((group) => group.id === selected);
102    return Box({
103      flexDirection: 'column',
104      gap: 1,
105      children: [
106        Text({ bold: true, children: ['RETRY MAP'] }),
107        Text({
108          children: [
109            'Inspect repetition before spending another attempt. No automatic blocking or model calls.',
110          ],
111        }),
112        Text({
113          children: [
114            'File-tool edits are observed; shell and external edits may be invisible. Identical failures can still be legitimate.',
115          ],
116        }),
117        ...(history.groups.length
118          ? history.groups
119              .slice()
120              .reverse()
121              .map((group) =>
122                Box({
123                  key: `group-${group.id}`,
124                  flexDirection: 'column',
125                  children: [
126                    Text({
127                      bold: group.count >= LIMITS.warningOccurrences,
128                      children: [
129                        `#${group.id} · ${group.count} occurrence(s) · ${history.status(group)}`,
130                      ],
131                    }),
132                    Text({ children: [`${group.agent}: ${group.command.slice(0, 200)}`] }),
133                    Button({
134                      key: `inspect-${group.id}`,
135                      label: `Inspect failure ${group.id}`,
136                      onPress: () => {
137                        selected = group.id;
138                        $.ui.invalidate('ui.render');
139                      },
140                    }),
141                  ],
142                }),
143              )
144          : [Text({ children: ['No Bash failures recorded in this session.'] })]),
145        ...(detail
146          ? [
147              Code({
148                source: `${detail.command}\n\n${detail.error}${detail.truncated ? '\n\n[Display truncated. The original tool result remains in the session.]' : ''}`,
149                language: 'text',
150                wrap: 'wrap',
151              }),
152            ]
153          : []),
154        Button({
155          key: 'clear',
156          label: 'Clear history',
157          onPress: () => {
158            generation += 1;
159            queue = queue
160              .then(() => {
161                history.clear();
162                selected = undefined;
163                $.ui.invalidate('ui.render');
164              })
165              .catch(() => undefined);
166          },
167        }),
168      ],
169    });
170  });
171};
172
src/history.ts 141 lines
1export const LIMITS = {
2  groups: 30,
3  commandCharacters: 16_000,
4  errorCharacters: 64_000,
5  displayErrorCharacters: 8_000,
6  warningOccurrences: 3,
7} as const;
8export interface Failure {
9  id: number;
10  command: string;
11  agent: string;
12  error: string;
13  count: number;
14  first: number;
15  last: number;
16  editRevision: number;
17  signature: string | undefined;
18  commandKey: string;
19  resolved: boolean;
20  truncated: boolean;
21}
22
23export function clean(text: string): string {
24  /* oxlint-disable no-control-regex -- Intentionally reject path controls or strip terminal control sequences. */
25  return text
26    .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
27    .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
28  /* oxlint-enable no-control-regex */
29}
30
31async function hash(text: string): Promise<string> {
32  const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
33  return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, '0')).join('');
34}
35
36export class History {
37  groups: Failure[] = [];
38  editRevision = 0;
39  private nextId = 1;
40  private active = new Map<string, number>();
41
42  clear(): void {
43    this.groups = [];
44    this.editRevision = 0;
45    this.nextId = 1;
46    this.active.clear();
47  }
48
49  observedEdit(): void {
50    this.editRevision += 1;
51    this.active.clear();
52  }
53
54  async failure(
55    command: string,
56    error: string | undefined,
57    agent: string,
58    now: number,
59    canCompare = true,
60  ): Promise<Failure> {
61    // Incomplete output is never used to assert that two failures are identical.
62    const comparable =
63      canCompare &&
64      command.length <= LIMITS.commandCharacters &&
65      error !== undefined &&
66      error.length > 0 &&
67      error.length <= LIMITS.errorCharacters;
68    const commandKey = await hash(JSON.stringify([agent, command]));
69    const signature = comparable ? await hash(error) : undefined;
70    const previous = this.groups.find((group) => group.id === this.active.get(commandKey));
71    if (
72      previous &&
73      signature &&
74      previous.signature === signature &&
75      previous.editRevision === this.editRevision &&
76      !previous.resolved
77    ) {
78      previous.count += 1;
79      previous.last = now;
80      return previous;
81    }
82    const group: Failure = {
83      id: this.nextId++,
84      command: clean(command.slice(0, LIMITS.commandCharacters)),
85      agent: clean(agent),
86      error: clean(
87        (error || 'Failure details unavailable. This occurrence is not comparable.').slice(
88          0,
89          LIMITS.displayErrorCharacters,
90        ),
91      ),
92      count: 1,
93      first: now,
94      last: now,
95      editRevision: this.editRevision,
96      signature,
97      commandKey,
98      resolved: false,
99      truncated:
100        command.length > LIMITS.commandCharacters ||
101        (error?.length ?? 0) > LIMITS.displayErrorCharacters,
102    };
103    this.groups.push(group);
104    if (this.groups.length > LIMITS.groups) this.groups.shift();
105    this.active.set(commandKey, group.id);
106    const retained = new Set(this.groups.map((group) => group.id));
107    for (const [key, id] of this.active) if (!retained.has(id)) this.active.delete(key);
108    return group;
109  }
110
111  async success(command: string, agent: string): Promise<void> {
112    const commandKey = await hash(JSON.stringify([agent, command]));
113    const previous = this.groups.find((group) => group.id === this.active.get(commandKey));
114    if (previous) previous.resolved = true;
115    this.active.delete(commandKey);
116  }
117
118  status(group: Failure): string {
119    if (group.resolved) return 'Tool later succeeded';
120    if (group.editRevision !== this.editRevision) return 'Before an observed file-tool edit';
121    if (!group.signature) return 'Incomplete evidence';
122    return group.count >= LIMITS.warningOccurrences
123      ? 'Repeated failure: inspect before retrying'
124      : 'Failure recorded';
125  }
126
127  summary(): string {
128    return [
129      'Retry Map — recent Bash tool failures',
130      'Exact command/error matches are grouped per agent, since the last successful Edit/Write/NotebookEdit observed by this mod. Shell or external edits may be invisible. Counts are not evidence of wasted tokens.',
131      ...this.groups
132        .slice()
133        .reverse()
134        .map(
135          (group) =>
136            `#${group.id} · ${group.count} occurrence(s) · ${this.status(group)}\n${group.agent}: ${group.command.slice(0, 200)}`,
137        ),
138    ].join('\n\n');
139  }
140}
141