SLOPSHOPPER

unstuck

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

newpanebandguardcommandtoast
★ 1v0.2.0MITupdated 2026-10-08sniperunder123/unstuck/unstuck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · unstuck
│ ┃ unstuck ✕ › fix the failing auth test and add an audit log call │ ┃ 🟢 All good │ ┃ ✗ revokes on logout ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏪ No green state yet (needs a passing test ⏺ Update(src/auth.ts) │ ┃ or build in git). ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ 🧹 Clean restart ] [ 🔎 2nd opinion ] [ 🔬 ⎿ 3 pass, 1 fail │ ┃ │ ┃ s: ⚙ Settings ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /unstuck │ ⎿ unstuck: unstuck menu opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · unstuck
🟢 All good ✗ revokes on logout ⏪ No green state yet (needs a passing test or build in git). [ 🧹 Clean restart ] [ 🔎 2nd opinion ] [ 🔬 Diagnose first s: ⚙ Settings
Pane · unstuck-settings
Detect ◉ The same error coming back ◉ The same command failing (even with new errors) ◉ Claude saying "fixed" when it is not ◉ Claude undoing its own edits ◉ Claude silencing errors (@ts-ignore, .skip(), ...) Act ◉ On red, tell Claude to stop and list hypotheses ○ Block edits that silence errors (instead of flagging them) ◉ Remember dead ends for this project across sessions Notify ◉ Pop-up notifications ◉ Red band above the prompt ◉ Status line while looping Sensitivity [ - ] 2 [ + ] signals before 🟡 [ - ] 3 [ + ] signals before 🔴 One signal = one repeat, fake fix, undone edit or silenced error.
README

unstuck

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

Claude stuck on the same failing test three times: unstuck shows a red band with ways 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.

What it catches

SignalWhat it means
Same errorThe 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 commandThe same command keeps failing, even though the error changes every time.
Fake fixClaude says "fixed", and the very next run fails the same way.
Ping-pongClaude writes back code it had removed earlier.
Silenced errorClaude adds @ts-ignore, as any, .skip(), eslint-disable, # type: ignore… to make the error go away.
Full contextMore 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.

What it does

Pop-up notifications for each signal

  • Tells you. A pop-up for each signal, a short status line while looping (nothing when all is fine), and a red band above the prompt on 🔴. The band shows what is going on, how long it has lasted and what it has cost.
  • Tells Claude. On 🔴, Claude is told to stop editing, list what it already tried, come up with 3 new hypotheses and confirm one before touching the code. When it silences an error, it is told to fix the root cause instead. You can also block those edits.
  • Gets you out, in one key. The prompts it writes for you wait in your box until you press Enter.
Way outWhat happens
⏪ Revert to last greenPuts 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 restartWrites a handoff note (goal, what failed, what not to retry), clears the session, and puts the note in your prompt.
🔎 2nd opinionA 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 firstPuts a prompt in your box asking Claude for logs or a minimal repro instead of another guess.
🌐 Search the errorPuts a prompt in your box asking Claude to search the web for the exact error.

The /unstuck pane with a second opinion

  • Remembers dead ends. Every clean restart and second opinion saves what failed in the mod's private storage, outside your project (nothing to commit by mistake). The next sessions in that project are told not to retry it. Notes expire after 14 days (5 at most): /unstuck dead-ends shows them, /unstuck forget clears them.

Install

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.

Use

You don't have to do anything: unstuck stays silent until Claude starts looping.

CommandWhat it does
/unstuckOpens the pane: what is going on, the second opinion, every way out
/unstuck settingsOpens the settings
/unstuck resetForgets the current loop
/unstuck dead-endsShows the dead ends remembered for this project
/unstuck forgetClears 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.

Settings

The settings pane

Every detector, intervention and notification can be turned on or off, and the sensitivity tuned. Settings are saved across sessions.

Good to know

  • Revert needs a git repository and a passing test or build during the session (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.
  • Errors are read from shell commands (tests, builds, scripts). An error inside a file edit is not counted yet.
  • Clean restart really clears the conversation. The handoff note is in your prompt: read it, then press Enter.
  • Tested on Windows so far. macOS and Linux should work but have not been tried yet: reports welcome.
  • No sound alert yet: the mod audio API plays nothing on Windows.

Develop

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.

License

MIT

Source 3 files
hooks/register.tsx 544 lines
1import { 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}
544
hooks/detect.ts 262 lines
1import 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}`
262
types/index.d.ts 63 lines
1export 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