Detects when Claude Code is going in circles, tells you clearly, and gets you out.

Claude Code is going in circles. unstuck notices, tells you, and gets you out.

You ask for a feature. Claude runs the tests, they fail. It tries a fix, same error. Another fix, same error. It reverts its own change, then slaps a .skip() on the test. Twenty minutes and a few dollars later you are still at actual: 53.973, expected: 53.97.
If you vibecode, you have been there. unstuck is a Claude Code mod that watches for these loops while they happen, tells you in plain words, and gives you one-key ways out.
| Signal | What it means |
|---|---|
| Same error | The same error keeps coming back. Line numbers, folders, addresses and durations are ignored, so a "new" error that is really the old one still counts. The failing test, the file and the values are kept: two different failures, or an actual value that changes, are not a repeat. |
| Same command | The same command keeps failing, even though the error changes every time. |
| Fake fix | Claude says "fixed", and the very next run fails the same way. |
| Ping-pong | Claude writes back code it had removed earlier. |
| Silenced error | Claude adds @ts-ignore, as any, .skip(), eslint-disable, # type: ignore… to make the error go away. |
| Full context | More than 80% of the context window is used while looping. Another try won't help, a fresh start will. |
Each signal raises a score. 🟡 means Claude may be looping, 🔴 means it is stuck.

| Way out | What happens |
|---|---|
| ⏪ Revert to last green | Puts the files back to the last time tests or the build passed, tells Claude its fixes failed, and gives you the command to undo the revert. |
| 🧹 Clean restart | Writes a handoff note (goal, what failed, what not to retry), clears the session, and puts the note in your prompt. |
| 🔎 2nd opinion | A fresh agent, without the polluted context, reads the code (read-only) and looks for the root cause. Its diagnosis shows up in the pane. |
| 🔬 Diagnose first | Puts a prompt in your box asking Claude for logs or a minimal repro instead of another guess. |
| 🌐 Search the error | Puts a prompt in your box asking Claude to search the web for the exact error. |

/unstuck dead-ends shows them, /unstuck forget clears them.Requires Claude Code with plugin function hooks (mods). Built and tested on Claude Code 2.1.291. In Claude Code, type:
/plugin install unstuck --marketplace sniperunder123/unstuck
Answer y to add the marketplace and pick a scope (the user scope works in every project). That's it: unstuck runs in the background from then on.
You don't have to do anything: unstuck stays silent until Claude starts looping.
| Command | What it does |
|---|---|
/unstuck | Opens the pane: what is going on, the second opinion, every way out |
/unstuck settings | Opens the settings |
/unstuck reset | Forgets the current loop |
/unstuck dead-ends | Shows the dead ends remembered for this project |
/unstuck forget | Clears them |
In the band or the pane, press <kbd>Ctrl</kbd>+<kbd>X</kbd> <kbd>Tab</kbd> to focus it, then: <kbd>r</kbd> revert, <kbd>c</kbd> clean restart, <kbd>o</kbd> second opinion, <kbd>u</kbd> use the second opinion, <kbd>d</kbd> diagnose, <kbd>w</kbd> web search, <kbd>x</kbd> not stuck.

Every detector, intervention and notification can be turned on or off, and the sensitivity tuned. Settings are saved across sessions.
npm test, pytest, cargo test, go test, tsc, …). Only real runner invocations count: ls tests/ or mkdir build do not. A piped run (npm test | tail) is judged on its output, not its exit code, and a run that passes because a test was skipped does not count as green. Revert puts back every file git tracks, your own changes since that point included: it gives you the command to undo it. Files created since stay where they are, and untracked files are never touched.claude --plugin-dir ./unstuck # load it from this folder
claude plugin test ./unstuck # 19 tests: detection logic + the band and panes on terminal and desktop
claude plugin validate ./unstuck
demo/ is a tiny shop cart with a rounding trap. Ask Claude to add a 10% discount and watch. docs/make_images.py draws the images of this README as terminal cell grids (HTML, captured with a headless browser).
See the roadmap for what's next.
hooks/register.tsx 544 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Healthy, Level, Opinion, Settings, Tracker } from '../types'
5import {
6 bump,
7 burnedOf,
8 claimsFix,
9 DEFAULTS,
10 diagnosePrompt,
11 EMPTY,
12 hackNote,
13 hacksAdded,
14 HANDOFF_PROMPT,
15 freshDeadEnds,
16 isGreen,
17 looksFailed,
18 isIdle,
19 isPingPong,
20 levelOf,
21 nudgeFor,
22 onError,
23 onSuccess,
24 opinionPrompt,
25 reasonsOf,
26 webPrompt,
27} from './detect'
28
29type $ = EngineInterface
30type RenderEvent = Parameters<$['ui']['resolve']>[0]
31
32const MENU = 'unstuck'
33const SETTINGS = 'unstuck-settings'
34// Kept in the mod's private store, never in the project: notes hold local paths and must not get committed.
35const deadEndsKey = async ($: $) => `dead-ends:${await $.session.cwd()}`
36
37const tracker = atom({ plugin: 'unstuck', key: 'tracker' } as const, EMPTY)
38const settings = atom({ plugin: 'unstuck', key: 'settings' } as const, DEFAULTS)
39const healthy = atom({ plugin: 'unstuck', key: 'healthy' } as const, null)
40const dismissed = atom({ plugin: 'unstuck', key: 'dismissed' } as const, false)
41const opinion = atom({ plugin: 'unstuck', key: 'opinion' } as const, null)
42
43const SECTIONS: [string, [keyof Settings, string][]][] = [
44 [
45 'Detect',
46 [
47 ['detectRepeat', 'The same error coming back'],
48 ['detectCommand', 'The same command failing (even with new errors)'],
49 ['detectPhantom', 'Claude saying "fixed" when it is not'],
50 ['detectPingPong', 'Claude undoing its own edits'],
51 ['detectHacks', 'Claude silencing errors (@ts-ignore, .skip(), ...)'],
52 ],
53 ],
54 [
55 'Act',
56 [
57 ['nudge', 'On red, tell Claude to stop and list hypotheses'],
58 ['blockHacks', 'Block edits that silence errors (instead of flagging them)'],
59 ['deadEnds', 'Remember dead ends for this project across sessions'],
60 ],
61 ],
62 [
63 'Notify',
64 [
65 ['toast', 'Pop-up notifications'],
66 ['band', 'Red band above the prompt'],
67 ['status', 'Status line while looping'],
68 ],
69 ],
70]
71
72// ponytail: per-file replaced texts live in the module, a hot reload forgets them
73const replaced = new Map<string, string[]>()
74let deadEnds = ''
75
76const usd = async ($: $) => {
77 try {
78 return (await $.session.usage()).cost?.usd ?? 0
79 } catch {
80 return 0
81 }
82}
83
84const money = (n: number) => (n >= 0.01 ? ` · ≈$${n.toFixed(2)} burned` : '')
85
86const toast = async ($: $, text: string) => {
87 if ((await read($, settings)).toast) $.ui.toast(text)
88}
89
90const showStatus = ($: $, t: Tracker, s: Settings) => {
91 const level = levelOf(t, s)
92 const why = reasonsOf(t, s)[0] ?? 'going in circles'
93
94 $.ui.status(
95 !s.status || level === 'green'
96 ? undefined
97 : level === 'red'
98 ? `🔴 Claude is stuck · ${why} · /unstuck`
99 : `🟡 Claude may be looping · ${why}`,
100 )
101}
102
103const setTracker = async ($: $, next: Tracker, quiet = false) => {
104 const s = await read($, settings)
105 const before = levelOf(await read($, tracker), s)
106 const after = levelOf(next, s)
107 await update($, tracker, () => next)
108 showStatus($, next, s)
109 if (quiet || after === before) return
110
111 if (after === 'red') {
112 await update($, dismissed, () => false)
113 await toast($, `🔴 Claude is stuck: ${reasonsOf(next, s).join(', ')}.\nWays out: ${s.band ? 'above the prompt' : '/unstuck'}`)
114 } else if (after === 'yellow' && before === 'green') {
115 await toast($, `🟡 Claude may be going in circles: ${reasonsOf(next, s).join(', ')}.`)
116 } else if (after === 'green') {
117 await toast($, '✅ Loop broken, back on track.')
118 }
119
120 if (after !== 'green') {
121 const { context } = await $.session.usage().catch(() => ({ context: { percent: 0 } }))
122 if ((context.percent ?? 0) >= 80) {
123 await toast($, `🧠 Context is ${Math.round(context.percent ?? 0)}% full: a clean restart will help more than another try.`)
124 }
125 }
126}
127
128const setSettings = async ($: $, patch: Partial<Settings>) => {
129 const next = { ...(await read($, settings)), ...patch }
130 next.yellowAt = Math.max(2, next.yellowAt)
131 next.redAt = Math.max(next.yellowAt, next.redAt)
132 await update($, settings, () => next)
133 await $.store.set('settings', next)
134 showStatus($, await read($, tracker), next)
135}
136
137const git = ($: $, ...args: string[]) => $.process.run(['git', ...args])
138
139/** Snapshot the working tree without touching it: a stash commit, or HEAD when clean. */
140const snapshot = async ($: $) => {
141 try {
142 const stash = (await git($, 'stash', 'create')).stdout.trim()
143 const sha = stash || (await git($, 'rev-parse', 'HEAD')).stdout.trim()
144 return /^[0-9a-f]{40}$/.test(sha) ? sha : null
145 } catch {
146 return null // ponytail: no git, no revert
147 }
148}
149
150const handoff = async ($: $) => {
151 const note = await $.model.fork({ prompt: HANDOFF_PROMPT })
152 if (!note.isAnswered) {
153 $.ui.toast(`unstuck: could not summarize the session (${note.reason}).`)
154 return null
155 }
156
157 return note.text.trim()
158}
159
160const rememberDeadEnd = async ($: $, note: string) => {
161 if (!(await read($, settings)).deadEnds) return
162
163 const now = await $.clock.now()
164 const date = new Date(now).toISOString().slice(0, 10)
165 deadEnds = freshDeadEnds(`${deadEnds}\n## ${date}\n\n${note}\n`, now)
166 await $.store.set(await deadEndsKey($), deadEnds).catch(() => undefined)
167}
168
169const revert = async ($: $) => {
170 const target = await read($, healthy)
171 if (target === null) {
172 $.ui.toast('unstuck: no healthy state yet (needs a passing test or build in a git repo).')
173 return
174 }
175
176 const backup = await snapshot($)
177 // ponytail: tracked files only; files created since stay, untracked files are never touched
178 const { exitCode, stderr } = await git($, 'restore', `--source=${target.sha}`, '--staged', '--worktree', '--', '.')
179 if (exitCode !== 0) {
180 $.ui.toast(`unstuck: revert failed: ${stderr.trim().slice(0, 200)}`)
181 return
182 }
183
184 const t = await read($, tracker)
185 await $.session.append({
186 message: {
187 type: 'user',
188 content: [
189 {
190 type: 'text',
191 text:
192 `[unstuck] The user reverted the working tree to the last state where tests/build passed. ` +
193 `Your previous fixes for "${t.excerpt}" are gone and did not work: do not repeat them.`,
194 },
195 ],
196 },
197 })
198 await setTracker($, EMPTY, true)
199 $.ui.toast(backup ? `⏪ Reverted. Undo with:\ngit restore --source=${backup} --worktree -- .` : '⏪ Reverted.')
200}
201
202const restart = async ($: $) => {
203 $.ui.toast('🧹 Writing a handoff note, then starting fresh…')
204 const note = await handoff($)
205 if (note === null) return
206
207 await rememberDeadEnd($, note)
208 await setTracker($, EMPTY, true)
209 await $.command.run({ command: 'clear', args: '' })
210 await $.prompt.fill({
211 text: `Fresh start on a task the previous session got stuck on.\n\n${note}\n\nList your hypotheses before editing anything.`,
212 })
213 $.ui.toast('🧹 Fresh session. Review the note in the prompt, then press Enter.')
214}
215
216const secondOpinion = async ($: $) => {
217 const current = await read($, opinion)
218 if (current?.status === 'running') {
219 $.ui.toast('🔎 A second opinion is already on its way.')
220 return
221 }
222
223 $.ui.toast('🔎 Asking a fresh agent for a second opinion…')
224 const note = await handoff($)
225 if (note === null) return
226
227 await rememberDeadEnd($, note)
228 const prompt = opinionPrompt(note)
229 const description = 'unstuck: second opinion'
230 let spawned = await $.agent.spawn({ prompt, description, subagentType: 'Explore' })
231 if (spawned.deny !== undefined) spawned = await $.agent.spawn({ prompt, description })
232 if (spawned.deny !== undefined || spawned.agentId === undefined) {
233 $.ui.toast(`unstuck: could not start an agent (${spawned.deny ?? 'no id'}).`)
234 return
235 }
236
237 const agentId = spawned.agentId
238 await update($, opinion, () => ({ agentId, status: 'running' as const, text: '' }))
239}
240
241const useOpinion = async ($: $) => {
242 const op = await read($, opinion)
243 if (op?.status !== 'done') return
244
245 await $.prompt.fill({
246 text: `A fresh agent investigated the bug you are stuck on. Its second opinion:\n\n${op.text}\n\nCheck it against the code, then fix the root cause it points to.`,
247 })
248 await update($, opinion, () => null)
249}
250
251const fill = ($: $, text: string) => $.prompt.fill({ text })
252
253type View = { t: Tracker; green: Healthy | null; op: Opinion | null }
254
255const viewOf = async ($: $): Promise<View> => ({
256 t: await read($, tracker),
257 green: await read($, healthy),
258 op: await read($, opinion),
259})
260
261/** Synchronous on purpose: JSX built after an await loses the element factory. */
262const Actions = ($: $, e: RenderEvent, { t, green, op }: View, isFull: boolean) => {
263 const { Box, Button } = $.ui.resolve(e)
264
265 return (
266 <Box flexDirection="row" flexWrap="wrap" gap={1}>
267 {op?.status === 'done' && (
268 <Button key="use-opinion" hotkey="u" variant="primary" label="💡 Use 2nd opinion" onPress={() => useOpinion($)} />
269 )}
270 {green !== null && (
271 <Button key="revert" hotkey="r" variant={op?.status === 'done' ? 'secondary' : 'primary'} label="⏪ Revert to last green" onPress={() => revert($)} />
272 )}
273 <Button key="restart" hotkey="c" label="🧹 Clean restart" onPress={() => restart($)} />
274 {op?.status !== 'done' && (
275 <Button
276 key="opinion"
277 hotkey="o"
278 label={op?.status === 'running' ? '🔎 Asking…' : '🔎 2nd opinion'}
279 onPress={() => secondOpinion($)}
280 />
281 )}
282 {isFull && <Button key="diagnose" hotkey="d" label="🔬 Diagnose first" onPress={() => fill($, diagnosePrompt(t))} />}
283 {isFull && <Button key="web" hotkey="w" label="🌐 Search the error" onPress={() => fill($, webPrompt(t))} />}
284 <Button key="reset" hotkey="x" dimColor label="Not stuck" onPress={() => setTracker($, EMPTY, true)} />
285 </Box>
286 )
287}
288
289export const register: Register = on => {
290 on('session.start', async ($, e, next) => {
291 const saved = (await $.store.get('settings')) as Partial<Settings> | undefined
292 const s = { ...DEFAULTS, ...saved }
293 await update($, settings, () => s)
294 // A tracker from an older version lacks today's counters: start clean.
295 await update($, tracker, t => (t && Object.keys(EMPTY).every(k => k in t) ? t : EMPTY))
296 showStatus($, await read($, tracker), s)
297 deadEnds = freshDeadEnds(String((await $.store.get(await deadEndsKey($))) ?? ''), await $.clock.now())
298 await $.command.register({
299 name: 'unstuck',
300 description: 'Ways out when Claude is going in circles',
301 argumentHint: '[settings|reset|dead-ends|forget]',
302 })
303
304 return next(e)
305 })
306
307 on('command.run', { command: 'unstuck' }, async ($, e) => {
308 const arg = e.args.trim()
309
310 if (arg === 'settings') {
311 await $.ui.open({ id: SETTINGS, title: 'unstuck · settings', focus: true })
312 return { text: 'unstuck settings opened.' }
313 }
314 if (arg === 'reset') {
315 await setTracker($, EMPTY, true)
316 return { text: 'unstuck: tracker reset.' }
317 }
318 if (arg === 'dead-ends') {
319 return { text: deadEnds.trim() || 'unstuck: no dead ends recorded for this project.' }
320 }
321 if (arg === 'forget') {
322 deadEnds = ''
323 await $.store.delete(await deadEndsKey($))
324 return { text: 'unstuck: dead ends for this project forgotten.' }
325 }
326
327 await $.ui.open({ id: MENU, title: 'unstuck', focus: true })
328 return { text: 'unstuck menu opened.' }
329 })
330
331 on('prompt.compose', async ($, e, next) => {
332 const composed = await next(e)
333 if (!deadEnds.trim() || !(await read($, settings)).deadEnds) return composed
334
335 return {
336 sections: [
337 ...composed.sections,
338 {
339 id: 'unstuck:dead-ends',
340 scope: 'session' as const,
341 text: `# Dead ends in this project\nEarlier sessions got stuck here. Do not retry what these notes say failed.\n\n${deadEnds}`,
342 },
343 ],
344 }
345 })
346
347 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
348 const ran = await next(e)
349 if (ran.deny !== undefined || e.run_in_background || e.agentId !== undefined) return ran
350
351 const before = await read($, tracker)
352
353 // `npm test | tail` exits 0 when the tests fail: trust the runner's output over the exit code.
354 if (ran.isError || looksFailed(e.command, ran.text ?? '')) {
355 const t = onError(before, ran.text ?? '', e.command, await $.clock.now(), await usd($))
356 const s = await read($, settings)
357 const shouldNudge = s.nudge && levelOf(t, s) === 'red' && !t.nudged
358 await setTracker($, shouldNudge ? { ...t, nudged: true } : t)
359
360 return shouldNudge ? { ...ran, context: [...(ran.context ?? []), nudgeFor(t, s)] } : ran
361 }
362
363 const t = onSuccess(before, e.command)
364 if (t !== before) await setTracker($, t)
365
366 if (isGreen(before, e.command)) {
367 const sha = await snapshot($)
368 if (sha) await update($, healthy, () => ({ sha, at: Date.now() }))
369 }
370
371 return ran
372 }).catch(($, e, next) => next(e)) // never let unstuck break a Bash call
373
374 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
375 if (e.agentId !== undefined) return next(e)
376
377 const s = await read($, settings)
378 const file = e.file_path
379 const isEdit = e.tool === 'Edit'
380 const written = isEdit ? e.new_string : e.content
381 const old = isEdit ? e.old_string : await $.fs.read(file).catch(() => '')
382 const hacks = s.detectHacks ? hacksAdded(file, old, written) : []
383
384 if (hacks.length > 0 && s.blockHacks) {
385 await toast($, `🙈 Blocked: Claude tried to silence an error with ${hacks.join(', ')}.`)
386 return { deny: hackNote(hacks) }
387 }
388
389 const ran = await next(e)
390 if (ran.deny !== undefined || ran.isError) return ran
391
392 const history = replaced.get(file) ?? []
393 const isUndo = s.detectPingPong && isPingPong(history, written)
394 replaced.set(file, [...history, old].slice(-30))
395
396 let t = await read($, tracker)
397 const now = await $.clock.now()
398 const name = file.split(/[\\/]/).pop()
399
400 if (isUndo) {
401 t = bump(t, 'pingpongs', now, await usd($))
402 await toast($, `↩️ Claude just undid one of its own earlier edits in ${name}.`)
403 }
404 if (hacks.length > 0) {
405 t = bump(t, 'hacks', now, await usd($))
406 await toast($, `🙈 Claude silenced an error in ${name}: ${hacks.join(', ')}.`)
407 }
408 if (isUndo || hacks.length > 0) await setTracker($, t)
409
410 return hacks.length > 0 ? { ...ran, context: [...(ran.context ?? []), hackNote(hacks)] } : ran
411 }).catch(($, e, next) => next(e)) // fail open: a broken detector never blocks an edit
412
413 on('turn.complete', async ($, e, next) => {
414 const op = await read($, opinion)
415
416 if (e.agentId !== undefined && op?.agentId === e.agentId) {
417 await update($, opinion, () => ({ ...op, status: 'done' as const, text: e.answer.trim() }))
418 await update($, dismissed, () => false)
419 $.ui.toast('💡 Second opinion ready. Press "Use 2nd opinion" above the prompt, or /unstuck.')
420 } else if (e.agentId === undefined && claimsFix(e.answer)) {
421 const t = await read($, tracker)
422 if (!isIdle(t)) await update($, tracker, x => ({ ...x, claimedFix: true }))
423 }
424
425 return next(e)
426 })
427
428 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
429 const t = await read($, tracker)
430 const s = await read($, settings)
431 const isHidden = await read($, dismissed)
432 const op = await read($, opinion)
433 const isRed = levelOf(t, s) === 'red'
434 const hasOpinion = op?.status === 'done'
435
436 if (e.props.hasSurvey || !s.band || isHidden || !(isRed || hasOpinion)) return next(e)
437
438 const { Box, Text, Button } = $.ui.resolve(e)
439 const minutes = Math.max(1, Math.round(((await $.clock.now()) - t.since) / 60000))
440 const view = await viewOf($)
441
442 return (
443 <Box flexDirection="column" borderStyle="round" borderColor={isRed ? 'error' : 'suggestion'} paddingX={1}>
444 <Box flexDirection="row" justifyContent="space-between">
445 <Text color={isRed ? 'error' : 'suggestion'} bold>
446 {isRed
447 ? `🔴 Claude is stuck · ${reasonsOf(t, s).join(' · ')} · ${minutes} min${money(burnedOf(t))}`
448 : '💡 A fresh agent has a second opinion on the bug'}
449 </Text>
450 <Button key="hide" plain dimColor label="hide" onPress={() => update($, dismissed, () => true)} />
451 </Box>
452 {isRed && t.excerpt !== '' && (
453 <Text dimColor wrap="truncate-end">
454 {t.excerpt}
455 </Text>
456 )}
457 {Actions($, e, view, false)}
458 </Box>
459 )
460 })
461
462 on('ui.render', { component: 'Pane', requestId: MENU }, async ($, e) => {
463 const t = await read($, tracker)
464 const s = await read($, settings)
465 const green = await read($, healthy)
466 const op = await read($, opinion)
467 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
468 const level: Level = levelOf(t, s)
469 const reasons = reasonsOf(t, s)
470 const headline = { green: '🟢 All good', yellow: '🟡 Claude may be looping', red: '🔴 Claude is stuck' }[level]
471 const color = { green: 'success', yellow: 'warning', red: 'error' }[level]
472 const view = await viewOf($)
473
474 return (
475 <Box flexDirection="column" gap={1}>
476 <Box flexDirection="column">
477 <Text bold color={color}>
478 {headline}
479 </Text>
480 {reasons.length > 0 && <Text>{reasons.join(' · ') + money(burnedOf(t))}</Text>}
481 {t.excerpt !== '' && (
482 <Text dimColor wrap="truncate-end">
483 {t.excerpt}
484 </Text>
485 )}
486 </Box>
487 <Text dimColor>
488 {green ? `⏪ Last green state: ${green.sha.slice(0, 7)}` : '⏪ No green state yet (needs a passing test or build in git).'}
489 </Text>
490 {op?.status === 'running' && <Text color="suggestion">🔎 A fresh agent is investigating…</Text>}
491 {op?.status === 'done' && (
492 <Box flexDirection="column">
493 <Text bold color="suggestion">
494 💡 Second opinion
495 </Text>
496 <Markdown key="opinion-text" text={op.text} />
497 </Box>
498 )}
499 {Actions($, e, view, true)}
500 <Button key="settings" hotkey="s" plain dimColor label="⚙ Settings" onPress={() => $.ui.open({ id: SETTINGS, title: 'unstuck · settings', focus: true })} />
501 </Box>
502 )
503 })
504
505 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
506 const s = await read($, settings)
507 const { Box, Text, Button } = $.ui.resolve(e)
508
509 const Stepper = (key: 'yellowAt' | 'redAt', label: string) => (
510 <Box flexDirection="row" gap={1}>
511 <Button key={`${key}-down`} label="-" onPress={() => setSettings($, { [key]: s[key] - 1 })} />
512 <Text bold>{s[key]}</Text>
513 <Button key={`${key}-up`} label="+" onPress={() => setSettings($, { [key]: s[key] + 1 })} />
514 <Text>{label}</Text>
515 </Box>
516 )
517
518 return (
519 <Box flexDirection="column" gap={1}>
520 {SECTIONS.map(([title, rows]) => (
521 <Box flexDirection="column">
522 <Text bold>{title}</Text>
523 {rows.map(([key, label]) => (
524 <Button
525 key={key}
526 plain
527 dimColor={!s[key]}
528 label={`${s[key] ? '◉' : '○'} ${label}`}
529 onPress={() => setSettings($, { [key]: !s[key] })}
530 />
531 ))}
532 </Box>
533 ))}
534 <Box flexDirection="column">
535 <Text bold>Sensitivity</Text>
536 {Stepper('yellowAt', 'signals before 🟡')}
537 {Stepper('redAt', 'signals before 🔴')}
538 <Text dimColor>One signal = one repeat, fake fix, undone edit or silenced error.</Text>
539 </Box>
540 </Box>
541 )
542 })
543}
544hooks/detect.ts 262 lines1import type { Level, Settings, Tracker } from '../types'
2
3export const DEFAULTS: Settings = {
4 detectRepeat: true,
5 detectCommand: true,
6 detectPhantom: true,
7 detectPingPong: true,
8 detectHacks: true,
9 nudge: true,
10 blockHacks: false,
11 deadEnds: true,
12 band: true,
13 toast: true,
14 status: true,
15 yellowAt: 2,
16 redAt: 3,
17}
18
19export const EMPTY: Tracker = {
20 key: '',
21 excerpt: '',
22 failCommand: '',
23 repeats: 0,
24 cmdFails: 0,
25 phantoms: 0,
26 pingpongs: 0,
27 hacks: 0,
28 since: 0,
29 usdAtStart: 0,
30 usd: 0,
31 claimedFix: false,
32 nudged: false,
33}
34
35// The last group is an assertion's values: when they change, Claude is making progress.
36const ERROR_LINE = /error|fail|exception|cannot|can't|not found|undefined|denied|refused|panic|actual|expected|received|!==|===/i
37
38/** A failing test's own line (node, jest, TAP, pytest, go): it names which test failed. */
39const TEST_LINE = /^\s*(?:✖|✗|×|●|not ok\b|FAIL(?:ED)?\b|--- FAIL)/
40const FRAME = /^\s*at\s/
41
42/** The lines that identify a failure: error messages, failing test names, and where it was thrown. */
43const errorLines = (text: string) => {
44 const lines = text.split('\n')
45 const kept = lines.filter(line => ERROR_LINE.test(line) || TEST_LINE.test(line))
46 const frame = lines.find(line => FRAME.test(line))
47 if (frame !== undefined && !kept.includes(frame)) kept.push(frame)
48
49 return kept.length > 0 ? kept : lines
50}
51
52/**
53 * What stays the same when the same failure comes back, and differs between two failures.
54 * Dropped: line and column numbers, folders (the file name stays), addresses, timestamps, durations.
55 * Kept: the test name, the file name and the values, so a changing `actual` reads as progress.
56 */
57// ponytail: regex heuristic, swap for per-tool parsers if it misgroups errors
58export const normalize = (text: string) =>
59 errorLines(text)
60 .join('\n')
61 .replace(/\d{4}-\d\d-\d\dT[\d:.]+Z?|\b\d\d:\d\d:\d\d(?:\.\d+)?\b/g, '<time>')
62 .replace(/(?:[A-Za-z]:)?(?:[\w.@-]*[\\/])+([\w.@-]*[A-Za-z][\w.@-]*)/g, '$1')
63 .replace(/:\d+(?::\d+)?\b/g, ':#')
64 .replace(/\bline \d+/gi, 'line #')
65 .replace(/0x[0-9a-f]+/gi, '<hex>')
66 .replace(/\d+(?:\.\d+)?\s?(?:ms|s)\b/g, '#ms')
67 .replace(/\s+/g, ' ')
68 .trim()
69 .slice(0, 400)
70
71const excerptOf = (text: string) => {
72 const lines = errorLines(text).map(line => line.trim())
73
74 return (lines.find(line => /\w(?:Error|Exception)\b/.test(line)) ?? lines.find(line => line && !/^exit code/i.test(line)) ?? '').slice(0, 160)
75}
76
77export const onError = (t: Tracker, text: string, command: string, now: number, usd: number): Tracker => {
78 const key = normalize(text)
79 const cmd = command.trim()
80 const isSameCommand = cmd === t.failCommand
81
82 if (key === t.key) {
83 return {
84 ...t,
85 failCommand: cmd,
86 repeats: t.repeats + 1,
87 cmdFails: isSameCommand ? t.cmdFails + 1 : 1,
88 phantoms: t.phantoms + (t.claimedFix ? 1 : 0),
89 usd,
90 claimedFix: false,
91 }
92 }
93
94 // A new error on the same command is still the same fight, and edit-side
95 // signals with no error yet belong to it too: keep their counters.
96 const isSameFight = isSameCommand || t.repeats === 0
97 const fresh = !isSameFight
98 ? { ...EMPTY, since: now, usdAtStart: usd }
99 : t.since
100 ? t
101 : { ...t, since: now, usdAtStart: usd }
102 return {
103 ...fresh,
104 key,
105 excerpt: excerptOf(text),
106 failCommand: cmd,
107 repeats: 1,
108 cmdFails: isSameCommand ? t.cmdFails + 1 : 1,
109 usd,
110 claimedFix: false,
111 }
112}
113
114/**
115 * The failing command passes, or tests/build pass: the loop is over. Not while
116 * an error was silenced: a test that passes because it was skipped proves nothing.
117 */
118export const onSuccess = (t: Tracker, command: string): Tracker =>
119 !isIdle(t) && t.hacks === 0 && (command.trim() === t.failCommand || isHealthCheck(command)) ? EMPTY : t
120
121/** A passing test/build is a state worth going back to, unless it was cheated. */
122export const isGreen = (t: Tracker, command: string) => isHealthCheck(command) && t.hacks === 0
123
124export const isIdle = (t: Tracker) => t.repeats === 0 && t.pingpongs === 0 && t.hacks === 0
125
126/** Counts an edit-side signal, starting the clock when nothing was going on. */
127export const bump = (t: Tracker, field: 'pingpongs' | 'hacks', now: number, usd: number): Tracker => ({
128 ...t,
129 [field]: t[field] + 1,
130 since: t.since || now,
131 usdAtStart: t.since ? t.usdAtStart : usd,
132 usd,
133})
134
135const FIX_CLAIM = /\b(fixed|resolved|should (now )?work|works now|corrig[ée]|r[ée]gl[ée]|r[ée]solu)\b/i
136
137export const claimsFix = (answer: string) => FIX_CLAIM.test(answer)
138
139const JS = /\.[cm]?[jt]sx?$/i
140const PY = /\.pyi?$/i
141
142// [marker, label, files it can hide an error in]
143const HACKS: [RegExp, string, RegExp][] = [
144 [/@ts-ignore/g, '@ts-ignore', JS],
145 [/@ts-nocheck/g, '@ts-nocheck', JS],
146 [/@ts-expect-error/g, '@ts-expect-error', JS],
147 [/eslint-disable/g, 'eslint-disable', JS],
148 [/\bas any\b/g, 'as any', JS],
149 [/\b(?:it|test|describe)\.skip\(/g, '.skip()', JS],
150 [/\bx(?:it|describe)\(/g, 'xit()', JS],
151 [/#\s*type:\s*ignore/g, '# type: ignore', PY],
152 [/#\s*noqa/g, '# noqa', PY],
153 [/@pytest\.mark\.skip/g, '@pytest.mark.skip', PY],
154 [/@SuppressWarnings/g, '@SuppressWarnings', /\.(java|kt)$/i],
155 [/#\[allow\(/g, '#[allow(...)]', /\.rs$/i],
156 [/\/\/\s*nolint/g, '//nolint', /\.go$/i],
157]
158
159const count = (text: string, re: RegExp) => text.match(re)?.length ?? 0
160
161/**
162 * Error-silencing markers the edit adds to a file of a language they work in
163 * (present more often after than before). Docs and other languages only talk about them.
164 */
165export const hacksAdded = (file: string, before: string, after: string) =>
166 HACKS.filter(([re, , lang]) => lang.test(file) && count(after, re) > count(before, re)).map(([, label]) => label)
167
168/** The edit writes back text it replaced earlier in the same file. */
169export const isPingPong = (replacedBefore: readonly string[], written: string) =>
170 written.trim().length > 0 && replacedBefore.includes(written)
171
172const points = (t: Tracker, s: Settings) => {
173 const errPts = t.repeats === 0 ? 0 : s.detectRepeat ? t.repeats : 1
174 const cmdPts = s.detectCommand ? Math.max(0, t.cmdFails - 1) : 0
175
176 return (
177 Math.max(errPts, cmdPts) +
178 (s.detectPhantom ? t.phantoms : 0) +
179 (s.detectPingPong ? t.pingpongs : 0) +
180 (s.detectHacks ? t.hacks : 0)
181 )
182}
183
184export const levelOf = (t: Tracker, s: Settings): Level => {
185 const p = points(t, s)
186
187 return p >= s.redAt ? 'red' : p >= s.yellowAt ? 'yellow' : 'green'
188}
189
190/** What is going on, in words, strongest signal first. */
191export const reasonsOf = (t: Tracker, s: Settings) =>
192 [
193 s.detectRepeat && t.repeats > 1 && `same error ${t.repeats}×`,
194 s.detectCommand && t.cmdFails > t.repeats && t.cmdFails > 1 && `same command failed ${t.cmdFails}×`,
195 s.detectPhantom && t.phantoms > 0 && `${t.phantoms} fake fix${t.phantoms > 1 ? 'es' : ''}`,
196 s.detectPingPong && t.pingpongs > 0 && `${t.pingpongs} undone edit${t.pingpongs > 1 ? 's' : ''}`,
197 s.detectHacks && t.hacks > 0 && `${t.hacks} silenced error${t.hacks > 1 ? 's' : ''}`,
198 ].filter((r): r is string => typeof r === 'string')
199
200export const burnedOf = (t: Tracker) => Math.max(0, t.usd - t.usdAtStart)
201
202const DEAD_END_DAYS = 14
203
204/**
205 * The dead-end notes still worth reading: the last 5, none older than 14 days.
206 * An old bug is usually fixed, and "do not retry X" would then mislead.
207 */
208export const freshDeadEnds = (text: string, now: number) =>
209 text
210 .split(/\n(?=## )/)
211 .map(entry => entry.trim())
212 .filter(entry => {
213 const date = /^## (\d{4}-\d\d-\d\d)/.exec(entry)?.[1]
214 return date !== undefined && now - Date.parse(date) <= DEAD_END_DAYS * 86_400_000
215 })
216 .slice(-5)
217 .join('\n\n')
218
219/** A test, build or type-check runner, at the start of a step (not the word "test" anywhere: `ls tests/` is no test run). */
220const RUNNER =
221 /^(?:(?:npx|pnpm|yarn|bun|npm)\s+(?:run\s+)?(?:test|build|lint|typecheck|check)\b|npm\s+t\b|(?:npx\s+|pnpm\s+(?:exec\s+)?)?(?:tsc|vitest|jest|mocha|eslint)\b|(?:python3?\s+-m\s+)?pytest\b|cargo\s+(?:test|build|check|clippy)\b|go\s+(?:test|build|vet)\b|make\s+(?:test|build|check)\b|dotnet\s+(?:test|build)\b|mvn\s+(?:\S+\s+)*(?:test|verify|package)\b|\.?\/?gradlew?\s+(?:test|build|check)\b|node\s+--test\b|deno\s+test\b)/
222
223/** Each step of a command line, without what it is piped into: `cd x && npm test | tail` -> `cd x`, `npm test`. */
224const steps = (command: string) => command.split(/&&|\|\||;/).map(step => step.split('|')[0]!.trim())
225
226export const isHealthCheck = (command: string) => steps(command).some(step => RUNNER.test(step))
227
228/** A runner's output that says it failed, for when the exit code lies (`npm test | tail`, `|| true`). */
229const FAILED_OUTPUT =
230 /\b[1-9]\d* (?:failed|failing|errors?)\b|^\s*(?:✖|✗|FAIL(?:ED)?\b|not ok\b|--- FAIL)|npm ERR!|\berror TS\d+|Traceback \(most recent call last\)|^\s*Tests?:\s+[1-9]\d* failed/im
231
232export const looksFailed = (command: string, output: string) => isHealthCheck(command) && FAILED_OUTPUT.test(output)
233
234export const nudgeFor = (t: Tracker, s: Settings) =>
235 `[unstuck] You are going in circles (${reasonsOf(t, s).join(', ')}). Stop editing. Before any change:\n` +
236 `1. List every fix you already tried.\n` +
237 `2. List 3 hypotheses for the root cause that those fixes did not address.\n` +
238 `3. Confirm the most likely one with logging or a minimal repro before touching the code.\n` +
239 `Do not repeat a previous fix, and do not silence the error.`
240
241export const hackNote = (labels: string[]) =>
242 `[unstuck] This edit adds ${labels.join(', ')}, which hides the problem instead of fixing it. ` +
243 `Unless the user asked for it, revert that part and fix the root cause.`
244
245export const HANDOFF_PROMPT =
246 'Write a handoff note for a fresh session that will continue this task with no memory of this conversation. ' +
247 'Plain text, under 200 words, these sections: Goal. Current error (exact message). ' +
248 'What was tried and failed (one line each). What NOT to retry. Suggested next step. ' +
249 'Reply with the note only.'
250
251export const opinionPrompt = (handoff: string) =>
252 `Another agent is stuck on a bug and keeps failing. Give a second opinion with fresh eyes.\n\n${handoff}\n\n` +
253 `Investigate the code read-only (do not edit anything). Find the root cause the previous attempts missed. ` +
254 `Reply in under 200 words: the most likely root cause, the evidence (file:line), and the fix to try.`
255
256export const diagnosePrompt = (t: Tracker) =>
257 `Diagnostic mode: do not try another fix for "${t.excerpt}". ` +
258 `Add logging or write a minimal repro that shows exactly where the assumption breaks, run it, and report what you found before changing any code.`
259
260export const webPrompt = (t: Tracker) =>
261 `Search the web for this exact error and summarize the known causes and fixes before trying anything else:\n${t.excerpt}`
262types/index.d.ts 63 lines1export type UnstuckSettings = {
2 detectRepeat: boolean
3 detectCommand: boolean
4 detectPhantom: boolean
5 detectPingPong: boolean
6 detectHacks: boolean
7 nudge: boolean
8 blockHacks: boolean
9 deadEnds: boolean
10 band: boolean
11 toast: boolean
12 status: boolean
13 /** Points before yellow. */
14 yellowAt: number
15 /** Points before red. */
16 redAt: number
17}
18
19export type Tracker = {
20 /** Normalized error, the identity of "the same error". */
21 key: string
22 /** First error line as Claude saw it, for display. */
23 excerpt: string
24 /** Command that keeps failing. */
25 failCommand: string
26 /** Same error in a row. */
27 repeats: number
28 /** Same command failing in a row, whatever the error. */
29 cmdFails: number
30 /** Claude said "fixed" and the same error came back. */
31 phantoms: number
32 /** Claude undid one of its own earlier edits. */
33 pingpongs: number
34 /** Claude silenced an error (ts-ignore, .skip(), ...). */
35 hacks: number
36 since: number
37 usdAtStart: number
38 usd: number
39 claimedFix: boolean
40 nudged: boolean
41}
42
43export type Healthy = { sha: string; at: number }
44
45export type Opinion = { agentId: string; status: 'running' | 'done'; text: string }
46
47export type Level = 'green' | 'yellow' | 'red'
48
49declare module 'claude-code' {
50 interface PluginState {
51 unstuck: {
52 tracker: Tracker
53 settings: UnstuckSettings
54 healthy: Healthy | null
55 dismissed: boolean
56 opinion: Opinion | null
57 }
58 }
59}
60
61/** Short alias for the module; the contract uses UnstuckSettings (claude-code has its own Settings). */
62export type Settings = UnstuckSettings
63