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

<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.
/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.
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.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.
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.
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.
hooks/register.ts 172 lines1import 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};
172src/history.ts 141 lines1export 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