SLOPSHOPPER

loop-breaker

Catches Claude going in circles, nudges it out, and tells you when it can't.

newguardcommandtoastprompt
v0.0.1no licenseupdated 2026-10-07matthews-wong/loop-breaker/mods/loop-breaker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loop-breaker
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /loop-breaker ⎿ loop-breaker: Loop Breaker is active (skeleton). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Loop Breaker

Status: skeleton (v0.0.1). The mod loads and answers /loop-breaker. Detection, nudges and the band arrive in later phases (plan).

What this shows

Claude Code sometimes goes in circles: re-running the same failing test, flipping a file between two "fixes", retrying a command you declined, or hammering a missing tool. Loop Breaker watches every tool call and detects these loops with cheap, deterministic rules. It first nudges Claude privately with a note on the failing result. If that doesn't help, or the loop is something Claude can't fix, it shows you one band with Stop / Hint / Ignore. Otherwise it stays out of sight.

Demo

Screenshot or recording to come with Phase 5 (the band).

How it was built

A Claude Code mod: a plugin of function hooks running inside the session.

FolderRole
hooks/register.tsxthe hooks module the engine loads; it only wires events
hooks/core/pure detection logic, no $ (testable in Node too)
hooks/adapter/everything that talks to the engine ($)
hooks/ui/band and pane drawing
tests/claude plugin test suites

Run it

From the repository root:

npm install                 # dev tooling only; the mod itself has no dependencies
npm run check               # format, typecheck, lint, validate, test
claude --plugin-dir ./mods/loop-breaker

Then type /loop-breaker in the session.

Dev loop. A --plugin-dir folder is watched. Saving a file reloads the module: register runs again, values in $.state survive, module variables do not. Trace hooks with claude --debug-file ./mod-debug.log --plugin-dir ./mods/loop-breaker. npm run types regenerates .claude-plugin/types/ (gitignored) by loading the mod once headlessly through its own command, which needs no model turn.

Notes / limitations

  • Built against Claude Code 2.1.292. Mods are early access; re-run npm run check after upgrading.
  • The command is /loop-breaker. /loops is a name Claude Code ships and refuses.

Dependencies

None at runtime. Dev tooling only: TypeScript, ESLint (typescript-eslint), Prettier.

Source 32 files
hooks/register.tsx 228 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  DEBUG_PREFIX,
6  DEBUG_TIMING_ON,
7  ENGINE_ORIGIN,
8  ESCALATION_TOAST_MS,
9  LOOP_BREAKER_COMMAND_DESCRIPTION,
10  PERSON_PROMPT_KINDS,
11  SKELETON_REPLY,
12  TIMELINE_CAP,
13} from './adapter/constants.ts'
14import {
15  flushStats,
16  loadSettings,
17  onConversationReset,
18  onPersonPrompt,
19  onTurnComplete,
20} from './adapter/lifecycle.ts'
21import { logOnce, observeToolCall, reportOverhead } from './adapter/observer.ts'
22import type { EnginePort } from './adapter/ports.ts'
23import { settingsFrom } from './adapter/settings.ts'
24import { addStats, emptyStats } from './adapter/stats.ts'
25
26// Session state, declared in types/index.d.ts. `plugin` and `key` are quoted
27// literals in consts of this file: validate reads state references only here.
28const detectionAtom = atom({ plugin: 'loop-breaker', key: 'detection' } as const, ``)
29const alertAtom = atom({ plugin: 'loop-breaker', key: 'alert' } as const, null)
30const timelineAtom = atom({ plugin: 'loop-breaker', key: 'timeline' } as const, [])
31const mainTurnIdAtom = atom({ plugin: 'loop-breaker', key: 'mainTurnId' } as const, null)
32const pendingStatsAtom = atom(
33  { plugin: 'loop-breaker', key: 'pendingStats' } as const,
34  emptyStats(),
35)
36
37/**
38 * The engine port over `$`: every engine call Loop Breaker makes, each a
39 * literal `$.noun.method(...)` call. It lives in this file because
40 * `claude plugin validate` follows `$` only into functions declared here;
41 * the adapter modules receive this port instead of `$`.
42 */
43function portFor($: EngineInterface): EnginePort {
44  return {
45    now: () => $.clock.now(),
46    updateDetection: async change => {
47      await update($, detectionAtom, change)
48    },
49    log: text => {
50      $.ui.log(`${DEBUG_PREFIX} ${text}`, { to: `debug` })
51    },
52    toast: text => {
53      $.ui.toast(text, { timeoutMs: ESCALATION_TOAST_MS })
54    },
55    alertedSignatures: async () => {
56      const timeline = await read($, timelineAtom)
57      return new Set(timeline.map(entry => entry.signature))
58    },
59    agentName: async agentId => {
60      try {
61        const agent = (await $.agent.list()).find(candidate => candidate.id === agentId)
62        return agent === undefined ? null : (agent.name ?? agent.type)
63      } catch {
64        return null
65      }
66    },
67    setAlert: async alert => {
68      await update($, alertAtom, () => alert)
69    },
70    clearAlertFor: async signatures => {
71      await update($, alertAtom, current =>
72        current !== null && signatures.includes(current.signature) ? null : current,
73      )
74    },
75    addTimeline: async entries => {
76      if (entries.length === 0) return
77      await update($, timelineAtom, timeline => [...timeline, ...entries].slice(-TIMELINE_CAP))
78    },
79    clearTimeline: async () => {
80      await update($, timelineAtom, () => [])
81    },
82    addPendingStats: async delta => {
83      await update($, pendingStatsAtom, pending => addStats(pending, delta))
84    },
85    takePendingStats: async () => {
86      let taken = emptyStats()
87      await update($, pendingStatsAtom, pending => {
88        taken = pending
89        return emptyStats()
90      })
91      return taken
92    },
93    appendNote: async (text, agentId) => {
94      await $.session.append({
95        message: { type: `user`, content: [{ type: `text`, text }] },
96        ...(agentId === undefined ? {} : { agentId }),
97      })
98    },
99    setMainTurnId: async turnId => {
100      await update($, mainTurnIdAtom, () => turnId)
101    },
102    storeGet: key => $.store.get(key),
103    storeSet: (key, value) => $.store.set(key, value),
104    // A literal name: validate lists the variables a module reads.
105    isDebugTimingOn: async () => (await $.env.get('LOOP_BREAKER_DEBUG')) === DEBUG_TIMING_ON,
106  }
107}
108
109/**
110 * Loop Breaker's hooks module: the only entry point the engine loads.
111 *
112 * It wires engine events to the adapter layer and holds no logic of its own
113 * beyond the engine port above. Detection lives in `core/` (pure, no `$`),
114 * orchestration in `adapter/` (through the port, never `$`), and drawing in
115 * `ui/`. This keeps the core testable in Node and keeps every `$` call
116 * literal, which `claude plugin validate` relies on.
117 *
118 * Every hook here is fail-open: its own work is wrapped so that an error is
119 * logged once (debug log) and the engine's behaviour goes on unchanged. Loop
120 * Breaker never denies a call, never rewrites an input, and changes a result
121 * only by appending its nudge for the model.
122 *
123 * Engine-analysed literals: the command's registration `name` and the
124 * `command.run` matcher are plain quoted strings ('loop-breaker'), not template
125 * literals or constants, which deliberately breaks the backtick string style.
126 * `claude plugin validate` only recognises a hook that answers its own
127 * command when both are quoted literals; otherwise it treats the hook as a
128 * gate on every command (Phase 1 spike finding). `LOOP_BREAKER_COMMAND` in
129 * `adapter/constants.ts` mirrors the name.
130 */
131export const register: Register = (on, options) => {
132  // Re-read on every load: a `/config` change reloads the module with new options.
133  let settings = settingsFrom(options)
134
135  on(`session.start`, async ($, e, next) => {
136    const port = portFor($)
137    try {
138      await $.command.register({
139        // Quoted literal, not a template or constant: required by validate (see above).
140        name: 'loop-breaker',
141        description: LOOP_BREAKER_COMMAND_DESCRIPTION,
142        immediate: true,
143      })
144    } catch (error) {
145      logOnce(port, `command registration`, error)
146    }
147    try {
148      settings = await loadSettings(port, settingsFrom(options))
149    } catch (error) {
150      logOnce(port, `session.start`, error)
151    }
152    return next(e)
153  })
154
155  // `/clear`, a resume or a fork starts a new conversation without a new `session.start`.
156  on(`classic.SessionStart`, { source: [`clear`, `resume`, `fork`] }, async ($, e, next) => {
157    const port = portFor($)
158    try {
159      await onConversationReset(port)
160    } catch (error) {
161      logOnce(port, `classic.SessionStart`, error)
162    }
163    return next(e)
164  })
165
166  // One store write, nothing slow: `session.end` hooks share a 1.5 s budget.
167  on(`session.end`, async ($, e, next) => {
168    const port = portFor($)
169    try {
170      if (settings.isTimingOn) reportOverhead(port)
171      await flushStats(port)
172    } catch (error) {
173      logOnce(port, `session.end`, error)
174    }
175    return next(e)
176  })
177
178  // The person's own prompt resets every streak; a notification or a peer's message does not.
179  // Observing only: the prompt goes in first, and a dropped prompt resets nothing (review I1).
180  on(`prompt.submit`, async ($, e, next) => {
181    const entered = await next(e)
182    if (entered.drop === undefined && PERSON_PROMPT_KINDS.has(e.origin.kind)) {
183      const port = portFor($)
184      try {
185        await onPersonPrompt(port)
186      } catch (error) {
187        logOnce(port, `prompt.submit`, error)
188      }
189    }
190    return entered
191  })
192
193  on(`turn.start`, async ($, e, next) => {
194    const port = portFor($)
195    try {
196      await port.setMainTurnId(e.turnId)
197    } catch (error) {
198      logOnce(port, `turn.start`, error)
199    }
200    return next(e)
201  })
202
203  // The main loop's turn only: a subagent's turn ending neither frees the budget nor flushes.
204  on(`turn.complete`, async ($, e, next) => {
205    const completed = await next(e)
206    if (e.agentId === undefined) {
207      const port = portFor($)
208      try {
209        await onTurnComplete(port)
210      } catch (error) {
211        logOnce(port, `turn.complete`, error)
212      }
213    }
214    return completed
215  })
216
217  // The observer: the tool always runs first; only the model's own calls are counted (R10).
218  on(`tool.call`, async ($, e, next) => {
219    if (next.origin.plugin !== ENGINE_ORIGIN) return next(e)
220    const startedAt = await $.clock.now()
221    const ran = await next(e)
222    return observeToolCall(portFor($), e, ran, startedAt, settings)
223  }).catch((_$, e, next) => next(e))
224
225  // Quoted literal matcher, as above. It must equal the registered name.
226  on(`command.run`, { command: 'loop-breaker' }, () => ({ text: SKELETON_REPLY }))
227}
228
hooks/adapter/constants.ts 62 lines
1/**
2 * Names, keys and limits the adapter layer shares with the engine and the
3 * tests, kept in one module so no literal is repeated.
4 */
5import type { PromptOrigin } from 'claude-code'
6
7/**
8 * The slash command Loop Breaker serves, without its slash.
9 *
10 * `register.tsx` must spell this name as a plain quoted literal (see the note
11 * there), so this constant mirrors it for tests and messages. Keep the two
12 * equal. `loops` is not used: Claude Code ships that name and refuses it.
13 */
14export const LOOP_BREAKER_COMMAND = `loop-breaker`
15
16/** The one line the typeahead and `/help` show for {@link LOOP_BREAKER_COMMAND}. */
17export const LOOP_BREAKER_COMMAND_DESCRIPTION = `Loop Breaker: show detected loops, stats and controls`
18
19/** What the command answers until the pane and stats land (Phase 5). */
20export const SKELETON_REPLY = `Loop Breaker is active (skeleton).`
21
22/** `next.origin.plugin` of a call the model made (spike R10); other mods' calls are not counted. */
23export const ENGINE_ORIGIN = `engine`
24
25/** Store key of the cross-session counts (versioned; counts only, never content). */
26export const STATS_STORE_KEY = `stats.v1`
27
28/** Store key of the substrings the person chose to always ignore (versioned). */
29export const IGNORE_STORE_KEY = `ignorePatterns.v1`
30
31/** Newest timeline entries kept in session state. */
32export const TIMELINE_CAP = 100
33
34/** How long the escalation toast stays, in milliseconds. */
35export const ESCALATION_TOAST_MS = 8000
36
37/** Prefix of every debug-log line, so `claude --debug` output is easy to filter. */
38export const DEBUG_PREFIX = `loop-breaker:`
39
40/**
41 * Prompt origins that are the person's own: their prompt resets every streak.
42 * A task notification, a peer's message or a schedule is not the person engaging.
43 */
44export const PERSON_PROMPT_KINDS: ReadonlySet<PromptOrigin[`kind`]> = new Set([
45  `composer`,
46  `bridge`,
47  `sdk`,
48  `slack-ping`,
49])
50
51/** Longest error message written to the debug log when a hook's own work fails (no call content). */
52export const ERROR_TEXT_CAP = 200
53
54/** The value of `LOOP_BREAKER_DEBUG` that turns on overhead timing (the name is a literal in `register.tsx`). */
55export const DEBUG_TIMING_ON = `1`
56
57/** Observed calls between two overhead reports when debug timing is on. */
58export const TIMING_REPORT_EVERY = 100
59
60/** Percentiles of the overhead report. */
61export const TIMING_PERCENTILES = [0.5, 0.95] as const
62
hooks/adapter/lifecycle.ts 76 lines
1/**
2 * What Loop Breaker does at the session's and the turn's edges: load the
3 * person's stored choices, reset streaks when the person speaks, reset the
4 * per-turn nudge budget, and flush counts to the store. Every function takes
5 * the engine port, never `$`.
6 */
7import { processTurnEnd, processUserPrompt } from '../core/pipeline.ts'
8import { emptyDetectionState } from '../core/window.ts'
9import { IGNORE_STORE_KEY, STATS_STORE_KEY } from './constants.ts'
10import type { EnginePort } from './ports.ts'
11import type { Settings } from './settings.ts'
12import { withStoredIgnores } from './settings.ts'
13import { parseDetection, serializeDetection } from './state.ts'
14import { addStats, hasCounts, statsFrom } from './stats.ts'
15
16/**
17 * Settings with the store's ignore list and the debug-timing switch read.
18 * Overlong `ignoreCommands` entries are reported once in the debug log (ER-5).
19 */
20export async function loadSettings(port: EnginePort, settings: Settings): Promise<Settings> {
21  if (settings.rejectedIgnoreCount > 0) {
22    port.log(
23      `ignored ${String(settings.rejectedIgnoreCount)} overlong ignoreCommands entries (set in /config)`,
24    )
25  }
26  const stored = await port.storeGet(IGNORE_STORE_KEY)
27  return { ...withStoredIgnores(settings, stored), isTimingOn: await port.isDebugTimingOn() }
28}
29
30/** The person spoke: every loop's streak starts over and the alert is taken down. */
31export async function onPersonPrompt(port: EnginePort): Promise<void> {
32  const now = await port.now()
33  await port.updateDetection(json =>
34    serializeDetection(processUserPrompt(parseDetection(json), now)),
35  )
36  await port.setAlert(null)
37}
38
39/**
40 * Adds the session's pending counts to the store's. The store is shared by
41 * every session on the machine and get-then-set is not atomic, so the key is
42 * read right before the write; a rare lost increment is accepted (the counts
43 * are informational). Counts taken but not written are put back for the next flush.
44 */
45export async function flushStats(port: EnginePort): Promise<void> {
46  const taken = await port.takePendingStats()
47  if (!hasCounts(taken)) return
48  try {
49    const stored = statsFrom(await port.storeGet(STATS_STORE_KEY))
50    await port.storeSet(STATS_STORE_KEY, addStats(stored, taken))
51  } catch (error) {
52    await port.addPendingStats(taken)
53    throw error
54  }
55}
56
57/** The main turn ended: the per-turn nudge budget resets and the counts are flushed. */
58export async function onTurnComplete(port: EnginePort): Promise<void> {
59  await port.updateDetection(json => serializeDetection(processTurnEnd(parseDetection(json))))
60  await port.setMainTurnId(null)
61  await flushStats(port)
62}
63
64/**
65 * A new conversation in the same process (`/clear`, a resume, a fork): no
66 * `session.start` fires, so detection, the alert and the timeline start over
67 * here, whether or not the host kept `$.state` (spike R9 pending). Counts
68 * were flushed by `session.end` just before.
69 */
70export async function onConversationReset(port: EnginePort): Promise<void> {
71  await port.updateDetection(() => serializeDetection(emptyDetectionState()))
72  await port.setAlert(null)
73  await port.clearTimeline()
74  await port.setMainTurnId(null)
75}
76
hooks/adapter/observer.ts 168 lines
1/**
2 * The tool-call observer: runs after the engine answered a call, feeds the
3 * core, carries out its decisions, and hands the model's result back with
4 * nothing changed but an appended nudge.
5 *
6 * Fail-open is the rule: any error in here is logged once and the engine's
7 * result is returned as it came. The observer never denies a call and never
8 * rewrites its input (in auto mode a rewritten input is denied).
9 *
10 * The nudge comes first. It is built from the decisions before anything else
11 * is awaited, and no later failure (the timeline, the alert, the counts, a
12 * refusal's note) can cost the model its note (Phase 4 review, I2).
13 */
14import type { ToolCallInput, ToolCallResult } from 'claude-code'
15
16import { MAIN_LOOP } from '../core/constants.ts'
17import { processRecord, recordFor } from '../core/pipeline.ts'
18import type { PipelineStep } from '../core/pipeline.ts'
19import type { Decision } from '../core/types.ts'
20import { ERROR_TEXT_CAP, TIMING_PERCENTILES, TIMING_REPORT_EVERY } from './constants.ts'
21import { observationOf } from './observation.ts'
22import type { EffectsPlan } from './plan.ts'
23import { planFor } from './plan.ts'
24import type { EnginePort } from './ports.ts'
25import type { Settings } from './settings.ts'
26import type { DetectionRead } from './state.ts'
27import { readDetection, serializeDetection } from './state.ts'
28
29/** Failures already logged in this load: each kind is logged once, not per call. */
30const loggedFailures = new Set<string>()
31
32/**
33 * Overheads measured since the last report, in milliseconds (debug timing
34 * only). Measured from the result's arrival to the observer's return: the one
35 * clock read before the tool runs is not included.
36 */
37const overheads: number[] = []
38
39/** Logs a failure of `site` once per load, without any call content. */
40export function logOnce(port: EnginePort, site: string, error: unknown): void {
41  if (loggedFailures.has(site)) return
42  loggedFailures.add(site)
43  const reason =
44    error instanceof Error ? `${error.name}: ${error.message.slice(0, ERROR_TEXT_CAP)}` : `unknown`
45  port.log(`${site} failed and was skipped (fail-open): ${reason}`)
46}
47
48/** The value at fraction `percentile` of sorted `values`. */
49function percentileOf(values: readonly number[], percentile: number): number {
50  const sorted = [...values].sort((a, b) => a - b)
51  return sorted[Math.min(sorted.length - 1, Math.floor(percentile * sorted.length))] ?? 0
52}
53
54/** Logs the percentiles of the overheads measured so far, then starts over (debug timing only). */
55export function reportOverhead(port: EnginePort): void {
56  if (overheads.length === 0) return
57  const report = TIMING_PERCENTILES.map(
58    percentile => `p${String(percentile * 100)} ${String(percentileOf(overheads, percentile))} ms`,
59  ).join(`, `)
60  port.log(`observer overhead over ${String(overheads.length)} calls: ${report}`)
61  overheads.length = 0
62}
63
64/** Records one call's overhead; every `TIMING_REPORT_EVERY` calls, logs the percentiles. */
65async function recordOverhead(port: EnginePort, since: number): Promise<void> {
66  overheads.push((await port.now()) - since)
67  if (overheads.length >= TIMING_REPORT_EVERY) reportOverhead(port)
68}
69
70/** The notes for the model among the decisions. */
71function nudgesOf(decisions: readonly Decision[]): readonly string[] {
72  return decisions.flatMap(decision => (decision.kind === `nudge` ? [decision.text] : []))
73}
74
75/**
76 * The result with the nudges attached for the model: appended to `context`
77 * (spike R1), or, for a refused call whose result takes none, appended as a
78 * row of its own (spike R1b). A failed append is logged; the refusal itself
79 * is returned as it came either way.
80 */
81async function withNudges(
82  ran: ToolCallResult,
83  nudges: readonly string[],
84  port: EnginePort,
85  agentId: string | undefined,
86): Promise<ToolCallResult> {
87  if (nudges.length === 0) return ran
88  if (ran.deny === undefined) return { ...ran, context: [...(ran.context ?? []), ...nudges] }
89  try {
90    for (const note of nudges) await port.appendNote(note, agentId)
91  } catch (error) {
92    logOnce(port, `refusal note`, error)
93  }
94  return ran
95}
96
97/** Carries out a plan's alert, timeline and counts. */
98async function carryOut(port: EnginePort, plan: EffectsPlan, loopId: string): Promise<void> {
99  const { escalation } = plan
100  if (escalation !== undefined) {
101    const agentName = loopId === MAIN_LOOP ? null : await port.agentName(loopId)
102    await port.setAlert({ ...escalation, agentName })
103    // The engine shows a toast under the plugin's name already.
104    port.toast(escalation.title)
105  } else if (plan.resolved.length > 0) {
106    await port.clearAlertFor(plan.resolved)
107  }
108  await port.addTimeline(plan.timeline)
109  await port.addPendingStats(plan.stats)
110}
111
112/** Signatures that already alerted, or none when the timeline can't be read. */
113async function alertedOrNone(port: EnginePort): Promise<ReadonlySet<string>> {
114  try {
115    return await port.alertedSignatures()
116  } catch (error) {
117    logOnce(port, `timeline read`, error)
118    return new Set()
119  }
120}
121
122/**
123 * Observes one finished tool call of the model's. Never throws: on any error
124 * the engine's result is returned unchanged.
125 *
126 * @param startedAt when the call was issued (read before `next`), so calls
127 *   issued together count as one attempt
128 */
129export async function observeToolCall(
130  port: EnginePort,
131  e: ToolCallInput,
132  ran: ToolCallResult,
133  startedAt: number,
134  settings: Settings,
135): Promise<ToolCallResult> {
136  try {
137    const at = await port.now()
138    // Built once, outside the compare-and-swap below: hashing a large file is never repeated.
139    const record = recordFor(observationOf(e, ran, { startedAt, at }), settings.config)
140    if (record === undefined) return ran
141    let step: PipelineStep | undefined
142    let read: DetectionRead | undefined
143    await port.updateDetection(json => {
144      read = readDetection(json)
145      step = processRecord(read.state, record, settings.config)
146      return serializeDetection(step.state)
147    })
148    if (read?.wasDiscarded === true)
149      port.log(`stored detection state was unreadable and started over`)
150    const decisions = step?.decisions ?? []
151    if (decisions.length === 0) {
152      if (settings.isTimingOn) await recordOverhead(port, at)
153      return ran
154    }
155    const answered = await withNudges(ran, nudgesOf(decisions), port, e.agentId)
156    try {
157      await carryOut(port, planFor(decisions, at, await alertedOrNone(port)), record.loopId)
158    } catch (error) {
159      logOnce(port, `decision effects`, error)
160    }
161    if (settings.isTimingOn) await recordOverhead(port, at)
162    return answered
163  } catch (error) {
164    logOnce(port, `tool.call observer`, error)
165    return ran
166  }
167}
168
hooks/adapter/ports.ts 45 lines
1/**
2 * The engine port: everything the adapter needs from Claude Code, as plain
3 * functions. `register.tsx` implements it with literal `$` calls, because
4 * `claude plugin validate` follows `$` only into functions of that file,
5 * never across an import. Every other adapter module takes this port, so it
6 * never sees `$` and tests can hand it a fake.
7 */
8import type {
9  LoopBreakerAlert,
10  LoopBreakerStats,
11  LoopBreakerTimelineEntry,
12} from '../../types/index.d.ts'
13
14/** What the adapter does to, and reads from, the engine. */
15export interface EnginePort {
16  /** The time now, in milliseconds (`$.clock.now`). */
17  readonly now: () => Promise<number>
18  /** Reads the detection JSON, applies `change`, writes it back (retried on a version miss). */
19  readonly updateDetection: (change: (json: string) => string) => Promise<void>
20  /** A line in the debug log, never the transcript. */
21  readonly log: (text: string) => void
22  /** A toast under the plugin's name. */
23  readonly toast: (text: string) => void
24  /** Signatures that already alerted this session, from the timeline. */
25  readonly alertedSignatures: () => Promise<ReadonlySet<string>>
26  /** A subagent's name or type, best effort: `null` when unknown. */
27  readonly agentName: (agentId: string) => Promise<string | null>
28  readonly setAlert: (alert: LoopBreakerAlert | null) => Promise<void>
29  /** Takes the alert down when it is about one of `signatures`. */
30  readonly clearAlertFor: (signatures: readonly string[]) => Promise<void>
31  readonly addTimeline: (entries: readonly LoopBreakerTimelineEntry[]) => Promise<void>
32  /** Empties the timeline: a new conversation (`/clear`) starts its own. */
33  readonly clearTimeline: () => Promise<void>
34  readonly addPendingStats: (delta: LoopBreakerStats) => Promise<void>
35  /** Takes the pending counts, leaving zeros behind (one atomic swap). */
36  readonly takePendingStats: () => Promise<LoopBreakerStats>
37  /** A note for the model on its own row: for a refused call, whose result takes no context. */
38  readonly appendNote: (text: string, agentId: string | undefined) => Promise<void>
39  readonly setMainTurnId: (turnId: string | null) => Promise<void>
40  readonly storeGet: (key: string) => Promise<unknown>
41  readonly storeSet: (key: string, value: unknown) => Promise<void>
42  /** Whether `LOOP_BREAKER_DEBUG=1` asks for overhead timing in the debug log. */
43  readonly isDebugTimingOn: () => Promise<boolean>
44}
45
hooks/adapter/settings.ts 46 lines
1/**
2 * What the person configured: the `userConfig` options (`register(on,
3 * options)`) plus the substrings they chose to always ignore, kept in the
4 * store. Resolved once per load; a `/config` change reloads the module.
5 */
6import type { PluginOptions } from 'claude-code'
7
8import { parseIgnoreList, resolveConfig } from '../core/config.ts'
9import type { DetectorConfig } from '../core/config.ts'
10import { IGNORE_ENTRY_MAX_COUNT } from '../core/constants.ts'
11
12/** Everything the hooks read about the person's choices. */
13export interface Settings {
14  readonly config: DetectorConfig
15  /** Whether overhead timing goes to the debug log (`LOOP_BREAKER_DEBUG`). */
16  readonly isTimingOn: boolean
17  /** `ignoreCommands` entries dropped as overlong: reported once in the debug log (ER-5). */
18  readonly rejectedIgnoreCount: number
19}
20
21/** Settings from the manifest options alone. */
22export function settingsFrom(options: PluginOptions): Settings {
23  return {
24    config: resolveConfig(options),
25    isTimingOn: false,
26    rejectedIgnoreCount: parseIgnoreList(options.ignoreCommands).rejected.length,
27  }
28}
29
30/**
31 * Settings with the store's ignore list added. Whatever the store holds is
32 * checked: only strings count, overlong ones are dropped, at most
33 * `IGNORE_ENTRY_MAX_COUNT`. A damaged value adds nothing.
34 */
35export function withStoredIgnores(settings: Settings, stored: unknown): Settings {
36  if (!Array.isArray(stored)) return settings
37  const text = stored
38    .filter((entry): entry is string => typeof entry === `string` && !entry.includes(`,`))
39    .slice(0, IGNORE_ENTRY_MAX_COUNT)
40    .join(`,`)
41  const extra = parseIgnoreList(text).entries
42  if (extra.length === 0) return settings
43  const ignoreCommands = [...new Set([...settings.config.ignoreCommands, ...extra])]
44  return { ...settings, config: { ...settings.config, ignoreCommands } }
45}
46
hooks/adapter/stats.ts 93 lines
1/**
2 * Effectiveness counts: how often Loop Breaker nudged, escalated and
3 * stopped, and how often a nudge resolved the loop. Counts only, never a
4 * command, an output or a file (non-negotiable: nothing persisted past the
5 * session but numbers). Pure functions; the store access is in `lifecycle.ts`.
6 */
7import type { LoopBreakerStats } from '../../types/index.d.ts'
8
9/** Counts that are all zero. */
10export function emptyStats(): LoopBreakerStats {
11  return {
12    loopsDetected: 0,
13    nudges: 0,
14    nudgesResolved: 0,
15    escalations: 0,
16    stops: 0,
17    ignores: 0,
18    byDetector: {},
19  }
20}
21
22/** A number field of an unknown record, or 0. */
23function countOf(value: unknown, key: string): number {
24  if (typeof value !== `object` || value === null) return 0
25  const field = (value as Readonly<Record<string, unknown>>)[key]
26  return typeof field === `number` && Number.isFinite(field) && field >= 0 ? field : 0
27}
28
29/**
30 * Counts read back from the store, whatever was there: unknown or damaged
31 * fields count as 0, so a bad value can never break a flush.
32 */
33export function statsFrom(value: unknown): LoopBreakerStats {
34  const byDetector: Record<string, { nudges: number; escalations: number }> = {}
35  const rawByDetector =
36    typeof value === `object` && value !== null
37      ? (value as Readonly<Record<string, unknown>>).byDetector
38      : undefined
39  if (typeof rawByDetector === `object` && rawByDetector !== null) {
40    for (const [detector, counts] of Object.entries(rawByDetector)) {
41      byDetector[detector] = {
42        nudges: countOf(counts, `nudges`),
43        escalations: countOf(counts, `escalations`),
44      }
45    }
46  }
47  return {
48    loopsDetected: countOf(value, `loopsDetected`),
49    nudges: countOf(value, `nudges`),
50    nudgesResolved: countOf(value, `nudgesResolved`),
51    escalations: countOf(value, `escalations`),
52    stops: countOf(value, `stops`),
53    ignores: countOf(value, `ignores`),
54    byDetector,
55  }
56}
57
58/** The sum of two sets of counts. */
59export function addStats(base: LoopBreakerStats, delta: LoopBreakerStats): LoopBreakerStats {
60  const byDetector: Record<string, { nudges: number; escalations: number }> = {
61    ...base.byDetector,
62  }
63  for (const [detector, counts] of Object.entries(delta.byDetector)) {
64    const current = byDetector[detector] ?? { nudges: 0, escalations: 0 }
65    byDetector[detector] = {
66      nudges: current.nudges + counts.nudges,
67      escalations: current.escalations + counts.escalations,
68    }
69  }
70  return {
71    loopsDetected: base.loopsDetected + delta.loopsDetected,
72    nudges: base.nudges + delta.nudges,
73    nudgesResolved: base.nudgesResolved + delta.nudgesResolved,
74    escalations: base.escalations + delta.escalations,
75    stops: base.stops + delta.stops,
76    ignores: base.ignores + delta.ignores,
77    byDetector,
78  }
79}
80
81/** Whether any count is above zero (nothing to flush otherwise). */
82export function hasCounts(stats: LoopBreakerStats): boolean {
83  return (
84    stats.loopsDetected +
85      stats.nudges +
86      stats.nudgesResolved +
87      stats.escalations +
88      stats.stops +
89      stats.ignores >
90    0
91  )
92}
93
hooks/core/pipeline.ts 128 lines
1/**
2 * The core's entry points: what the adapter calls on each engine event.
3 * Each is a pure function from the detection state (one `$.state` value) to
4 * the next state plus the decisions to carry out.
5 */
6import { NEVER_COUNTED_TOOLS } from './constants.ts'
7import type { DetectorConfig } from './config.ts'
8import { DETECTORS } from './detectors/index.ts'
9import { toRecord } from './fingerprint.ts'
10import { tailOf } from './normalize.ts'
11import {
12  applyRecord,
13  applySignal,
14  applyTurnEnd,
15  applyUserPrompt,
16  ignoreSignature,
17  pruneLoops,
18  setPaused,
19} from './policy.ts'
20import type { CallRecord, Decision, DetectionState, ToolObservation } from './types.ts'
21import { appendRecord, resetAllForUserPrompt, windowOf, withLoop } from './window.ts'
22
23/** The next state and what the adapter should do about it. */
24export interface PipelineStep {
25  readonly state: DetectionState
26  readonly decisions: readonly Decision[]
27  /** The record built for the observation, when it was counted. */
28  readonly record?: CallRecord
29}
30
31/** Whether one of the user's `ignoreCommands` substrings appears in the call's command or label. */
32function matchesIgnoreList(
33  record: CallRecord,
34  observation: ToolObservation,
35  config: DetectorConfig,
36): boolean {
37  if (config.ignoreCommands.length === 0) return false
38  const command =
39    typeof observation.input.command === `string` ? tailOf(observation.input.command) : ``
40  const haystacks = [command.toLowerCase(), record.label.toLowerCase()]
41  return config.ignoreCommands.some(entry => haystacks.some(haystack => haystack.includes(entry)))
42}
43
44/** Whether an observation stays out of detection entirely. */
45function isUncounted(observation: ToolObservation): boolean {
46  return (
47    NEVER_COUNTED_TOOLS.has(observation.tool) ||
48    observation.outcome === `interrupted` ||
49    observation.outcome === `background`
50  )
51}
52
53/**
54 * The record for one finished tool call, or `undefined` when the call stays
55 * out of detection (never-counted tools, interrupts, background launches,
56 * the ignore list). Pure and independent of the state, so the adapter builds
57 * it once, outside its state compare-and-swap, however often that retries.
58 */
59export function recordFor(
60  observation: ToolObservation,
61  config: DetectorConfig,
62): CallRecord | undefined {
63  if (isUncounted(observation)) return undefined
64  const record = toRecord(observation)
65  return matchesIgnoreList(record, observation, config) ? undefined : record
66}
67
68/**
69 * Processes one record: append it to its loop's window, resolve the
70 * signatures it breaks, run the detectors (first in precedence wins) and
71 * apply the policy.
72 */
73export function processRecord(
74  state: DetectionState,
75  record: CallRecord,
76  config: DetectorConfig,
77): PipelineStep {
78  const window = appendRecord(windowOf(state, record.loopId, record.at), record)
79  const withWindow = withLoop(state, record.loopId, window)
80  const livePolicy = pruneLoops(withWindow.policy, new Set(Object.keys(withWindow.loops)))
81  const resolved = applyRecord(livePolicy, record)
82
83  const signal = DETECTORS.map(detector => detector.detect(window, record, config)).find(
84    found => found !== undefined,
85  )
86  const applied =
87    signal === undefined
88      ? { policy: resolved.policy, decisions: [] }
89      : applySignal(resolved.policy, signal, config, record.at)
90
91  return {
92    state: { ...withWindow, policy: applied.policy },
93    decisions: [...resolved.decisions, ...applied.decisions],
94    record,
95  }
96}
97
98/** Processes one finished tool call: `recordFor`, then `processRecord`. */
99export function processObservation(
100  state: DetectionState,
101  observation: ToolObservation,
102  config: DetectorConfig,
103): PipelineStep {
104  const record = recordFor(observation, config)
105  return record === undefined ? { state, decisions: [] } : processRecord(state, record, config)
106}
107
108/** A prompt from the person: every loop's streaks start over. */
109export function processUserPrompt(state: DetectionState, now: number): DetectionState {
110  const reset = resetAllForUserPrompt(state, now)
111  return { ...reset, policy: applyUserPrompt(reset.policy) }
112}
113
114/** The turn ended: the per-turn nudge budget resets. */
115export function processTurnEnd(state: DetectionState): DetectionState {
116  return { ...state, policy: applyTurnEnd(state.policy) }
117}
118
119/** The person pressed Ignore on a signature. */
120export function processIgnore(state: DetectionState, signature: string): DetectionState {
121  return { ...state, policy: ignoreSignature(state.policy, signature) }
122}
123
124/** `/loop-breaker off|on`. */
125export function processPause(state: DetectionState, isPaused: boolean): DetectionState {
126  return { ...state, policy: setPaused(state.policy, isPaused) }
127}
128
hooks/core/window.ts 139 lines
1/**
2 * Loop windows: the bounded, per-loop record of calls since the last user
3 * prompt, plus each loop's file edit histories. Everything is plain data with
4 * pure updates, so the whole detection state round-trips through `$.state`
5 * (it survives hot reloads) and through JSON.
6 */
7import {
8  DETECTION_STATE_VERSION,
9  MAIN_LOOP,
10  MAX_LOOPS,
11  MAX_TRACKED_FILES,
12  WINDOW_CAP,
13} from './constants.ts'
14import { emptyFileHistory, recordFileChange } from './file-tracker.ts'
15import type {
16  CallRecord,
17  DetectionState,
18  FileHistory,
19  LoopId,
20  LoopWindow,
21  PolicyState,
22} from './types.ts'
23
24/** A loop with nothing recorded yet. */
25export function emptyWindow(now: number): LoopWindow {
26  return { records: [], files: {}, touchedAt: now }
27}
28
29/** A policy that remembers nothing. */
30export function emptyPolicy(): PolicyState {
31  return { signatures: {}, nudgesThisTurn: 0, covered: {}, ignored: [], isPaused: false }
32}
33
34/** The state a session starts with. */
35export function emptyDetectionState(): DetectionState {
36  return { version: DETECTION_STATE_VERSION, loops: {}, policy: emptyPolicy() }
37}
38
39/** Drops the least recently touched entries beyond `limit`. */
40function keepMostRecent<T extends { readonly touchedAt: number }>(
41  entries: Readonly<Record<string, T>>,
42  limit: number,
43): Readonly<Record<string, T>> {
44  const keys = Object.keys(entries)
45  if (keys.length <= limit) return entries
46  const kept = Object.entries(entries)
47    .sort(([, a], [, b]) => b.touchedAt - a.touchedAt)
48    .slice(0, limit)
49  return Object.fromEntries(kept)
50}
51
52/** Updates the edit history of the record's file, when it changed one. */
53function withFileChange(
54  window: LoopWindow,
55  record: CallRecord,
56): Readonly<Record<string, FileHistory>> {
57  if (!record.isEdit || record.file === undefined) return window.files
58  const history = window.files[record.file] ?? emptyFileHistory(record.at)
59  const files = { ...window.files, [record.file]: recordFileChange(history, record) }
60  return keepMostRecent(files, MAX_TRACKED_FILES)
61}
62
63/** Appends a record (oldest dropped past `WINDOW_CAP`) and updates file histories. */
64export function appendRecord(window: LoopWindow, record: CallRecord): LoopWindow {
65  const records = [...window.records, record]
66  return {
67    records: records.length > WINDOW_CAP ? records.slice(-WINDOW_CAP) : records,
68    files: withFileChange(window, record),
69    touchedAt: record.at,
70  }
71}
72
73/**
74 * A fresh window for a new user prompt: streaks, cycles and edit histories
75 * start over (prior-art consensus: detection scope is since the last user message).
76 */
77export function resetForUserPrompt(_window: LoopWindow, now: number): LoopWindow {
78  return emptyWindow(now)
79}
80
81/** The window of `loopId`, or an empty one. */
82export function windowOf(state: DetectionState, loopId: LoopId, now: number): LoopWindow {
83  return state.loops[loopId] ?? emptyWindow(now)
84}
85
86/** Whether a window holds a failure or denial: a streak that eviction would erase. */
87function hasFailure(window: LoopWindow): boolean {
88  return window.records.some(record => record.outcome === `error` || record.outcome === `denied`)
89}
90
91/**
92 * The subagent windows to keep: those with failures first, then the most
93 * recently touched. Plain least-recently-used eviction thrashes when more
94 * subagents than slots take turns (a 9-way fan-out evicts each one just
95 * before its next call), and a looping subagent would never build a streak.
96 */
97function keepSubagents(
98  subagents: Readonly<Record<LoopId, LoopWindow>>,
99  limit: number,
100): Readonly<Record<LoopId, LoopWindow>> {
101  const entries = Object.entries(subagents)
102  if (entries.length <= limit) return subagents
103  const kept = entries
104    .sort(
105      ([, a], [, b]) => Number(hasFailure(b)) - Number(hasFailure(a)) || b.touchedAt - a.touchedAt,
106    )
107    .slice(0, limit)
108  return Object.fromEntries(kept)
109}
110
111/**
112 * Stores a loop's window, evicting subagent loops beyond `MAX_LOOPS` (healthy,
113 * least recently touched ones first). The main loop is never evicted: many
114 * short-lived subagents must not erase the main conversation's streak.
115 */
116export function withLoop(
117  state: DetectionState,
118  loopId: LoopId,
119  window: LoopWindow,
120): DetectionState {
121  const { [MAIN_LOOP]: main, ...subagents } = { ...state.loops, [loopId]: window }
122  const keptSubagents = keepSubagents(subagents, main === undefined ? MAX_LOOPS : MAX_LOOPS - 1)
123  return {
124    ...state,
125    loops: main === undefined ? keptSubagents : { [MAIN_LOOP]: main, ...keptSubagents },
126  }
127}
128
129/** Every loop window reset for a new user prompt. */
130export function resetAllForUserPrompt(state: DetectionState, now: number): DetectionState {
131  const loops = Object.fromEntries(
132    Object.entries(state.loops).map(([loopId, window]) => [
133      loopId,
134      resetForUserPrompt(window, now),
135    ]),
136  )
137  return { ...state, loops }
138}
139
hooks/adapter/state.ts 63 lines
1/**
2 * The detection core's state as Loop Breaker keeps it in `$.state`: one JSON
3 * string, so the observer makes a single write per tool call (spike R7), and
4 * so a value written by an older build that no longer parses is replaced by
5 * a fresh state instead of breaking detection (fail-safe).
6 *
7 * The `$.state` atoms themselves are declared in `register.tsx`: `claude
8 * plugin validate` reads a state reference only from a const of that file.
9 */
10import { DETECTION_STATE_VERSION } from '../core/constants.ts'
11import type { DetectionState } from '../core/types.ts'
12import { emptyDetectionState } from '../core/window.ts'
13
14/** Whether a value is a plain object. */
15function isObject(value: unknown): value is Readonly<Record<string, unknown>> {
16  return typeof value === `object` && value !== null && !Array.isArray(value)
17}
18
19/** Whether parsed JSON has the detection state's outline (written by this build). */
20function isDetectionState(value: unknown): value is DetectionState {
21  if (!isObject(value) || value.version !== DETECTION_STATE_VERSION || !isObject(value.loops))
22    return false
23  const { policy } = value
24  return (
25    isObject(policy) &&
26    isObject(policy.signatures) &&
27    isObject(policy.covered) &&
28    Array.isArray(policy.ignored) &&
29    typeof policy.nudgesThisTurn === `number` &&
30    typeof policy.isPaused === `boolean`
31  )
32}
33
34/** The detection state read back, and whether a stored value had to be thrown away. */
35export interface DetectionRead {
36  readonly state: DetectionState
37  /** A non-empty value that did not parse or was of another version (it is logged once). */
38  readonly wasDiscarded: boolean
39}
40
41/** The detection state stored as JSON, or a fresh one when it is absent or unreadable. */
42export function readDetection(json: string): DetectionRead {
43  if (json === ``) return { state: emptyDetectionState(), wasDiscarded: false }
44  try {
45    const value: unknown = JSON.parse(json)
46    return isDetectionState(value)
47      ? { state: value, wasDiscarded: false }
48      : { state: emptyDetectionState(), wasDiscarded: true }
49  } catch {
50    return { state: emptyDetectionState(), wasDiscarded: true }
51  }
52}
53
54/** The detection state stored as JSON (see `readDetection`). */
55export function parseDetection(json: string): DetectionState {
56  return readDetection(json).state
57}
58
59/** The detection state as stored. */
60export function serializeDetection(state: DetectionState): string {
61  return JSON.stringify(state)
62}
63
hooks/core/constants.ts 109 lines
1/**
2 * Limits and lists the detection core shares. Every number here was chosen in
3 * the plan (phase-2.md) from prior art; Phase 3 calibrates the thresholds in
4 * `config.ts`, not these structural limits.
5 */
6
7/** The loop id of the main conversation; subagents use their `agentId`. */
8export const MAIN_LOOP = `main`
9
10/** The detection state's format version; a state of another version is replaced, not read. */
11export const DETECTION_STATE_VERSION = 1
12
13/** Calls kept per loop, since the last user prompt (OpenHands 20, OpenClaw 30). */
14export const WINDOW_CAP = 30
15
16/** Loops tracked at once; beyond this, healthy subagent loops are evicted, least recently touched first. */
17export const MAX_LOOPS = 8
18
19/** Files whose edit history is tracked per loop. */
20export const MAX_TRACKED_FILES = 20
21
22/** States remembered per tracked file. */
23export const FILE_HISTORY_CAP = 20
24
25/** Characters of tool output the core ever looks at: the tail, where errors surface. */
26export const OUTPUT_TAIL_CHARS = 4096
27
28/** Error-looking lines kept from one output when building fingerprints. */
29export const MAX_ERROR_LINES = 20
30
31/** Longest human snippet carried into state, the band and nudges. */
32export const SNIPPET_CAP = 200
33
34/** Longest action label (`npm test`, `Edit src/a.ts`). */
35export const LABEL_CAP = 60
36
37/** Characters of a generic tool input serialised into its args key. */
38export const ARGS_KEY_INPUT_CAP = 2048
39
40/** Calls back an error class must recur within to count as an environment blocker. */
41export const ENV_BLOCKER_SPAN = 10
42
43/** Model-facing nudges allowed per turn, across all loops and signatures. */
44export const MAX_NUDGES_PER_TURN = 2
45
46/** Share of a new signal's evidence already covered by an earlier nudge that suppresses it. */
47export const EVIDENCE_OVERLAP_LIMIT = 0.5
48
49/** Evidence ids remembered per loop for overlap suppression. */
50export const COVERED_EVIDENCE_CAP = 60
51
52/** Extra recurrences after escalation before an opt-in auto-stop. */
53export const AUTO_STOP_DELTA = 2
54
55/** Periods the cycle detector looks for (period 1 is a plain repeat). */
56export const CYCLE_PERIODS = [2, 3, 4] as const
57
58/** Tools that never count: planning, bookkeeping and asking the user. */
59export const NEVER_COUNTED_TOOLS: ReadonlySet<string> = new Set([
60  `TodoWrite`,
61  `ToolSearch`,
62  `AskUserQuestion`,
63  `ScheduleWakeup`,
64  `EnterPlanMode`,
65  `ExitPlanMode`,
66  `TaskCreate`,
67  `TaskUpdate`,
68])
69
70/** Tools whose whole purpose is waiting on something: the poll class. */
71export const POLL_TOOLS: ReadonlySet<string> = new Set([
72  `Monitor`,
73  `TaskGet`,
74  `GetTask`,
75  `TaskList`,
76  `Poll`,
77  `TaskOutput`,
78  `BashOutput`,
79])
80
81/** Tools that change files and so count as "progress" between verification runs. */
82export const EDIT_TOOLS: ReadonlySet<string> = new Set([`Edit`, `Write`, `NotebookEdit`])
83
84/** Model-facing text is tagged with this, so the transcript says who wrote it. */
85export const NUDGE_TAG = `[Loop Breaker]`
86
87/** Segments of an absolute path kept after normalisation (`<abs>/proj/src/users.ts`; `/api/v1/users` ≠ `/api/v2/users`). */
88export const ABSOLUTE_PATH_KEPT_SEGMENTS = 3
89
90/** UTF-16 high-surrogate range: a cut must not leave one dangling. */
91export const HIGH_SURROGATE_FIRST = 0xd800
92export const HIGH_SURROGATE_LAST = 0xdbff
93
94/** Longest nudge sent to the model. */
95export const NUDGE_CAP = 400
96
97/** Longest band title. */
98export const TITLE_CAP = 120
99
100/** Signatures the user ignored, remembered per session. */
101export const IGNORED_CAP = 100
102
103/** Signatures the policy remembers at once; the least recently seen go first. */
104export const MAX_SIGNATURES = 200
105
106/** User ignore entries kept, and the longest entry accepted. */
107export const IGNORE_ENTRY_MAX_COUNT = 50
108export const IGNORE_ENTRY_MAX_LENGTH = 200
109
hooks/core/types.ts 215 lines
1import type { DETECTION_STATE_VERSION } from './constants.ts'
2
3/**
4 * The detection core's vocabulary. Every type is plain JSON-serialisable data
5 * so the adapter can keep the whole detection state in one `$.state` value
6 * (one write per tool call, spike R7) and the core can run unchanged in Node.
7 */
8
9/** `main` for the main conversation, else the subagent's `agentId`. */
10export type LoopId = string
11
12/** How a tool call ended, as far as loop detection cares. */
13export type Outcome = `ok` | `error` | `denied` | `interrupted` | `background`
14
15/** Failure classes the model usually cannot fix by itself, plus timeouts. */
16export type ErrorClass =
17  | `command-not-found`
18  | `permission`
19  | `network`
20  | `auth`
21  | `rate-limit`
22  | `missing-module`
23  | `disk`
24  | `timeout`
25
26/** The detectors, in precedence order (see `detectors/index.ts`). */
27export type DetectorId =
28  | `env-blocker`
29  | `denied-retry`
30  | `repeat-failure`
31  | `same-error`
32  | `edit-thrash`
33  | `cycle`
34  | `idle-repeat`
35  | `poll`
36
37/**
38 * One finished tool call, described without any engine type. The adapter
39 * builds it from `tool.call`'s input and result (Phase 4).
40 */
41export interface ToolObservation {
42  /** The engine's `tool_use_id`. */
43  readonly id: string
44  readonly loopId: LoopId
45  readonly tool: string
46  readonly input: Readonly<Record<string, unknown>>
47  readonly outcome: Outcome
48  /** What the model read: the error text on failure (starts `Exit code N` for Bash), else the output. */
49  readonly text?: string
50  /** Edit only: the file's content before the edit (`result.originalFile`), `null` for none. */
51  readonly originalFile?: string | null
52  /** The engine's own read-only verdict for this call. */
53  readonly isReadOnly: boolean
54  /** Milliseconds, as the adapter's clock reads them: when the result arrived. */
55  readonly at: number
56  /**
57   * When the model issued the call. Absent means after every earlier result;
58   * earlier means it was issued together with others (one message, several calls).
59   */
60  readonly startedAt?: number
61}
62
63/** A tool call after normalisation: everything detectors need, nothing they don't. */
64export interface CallRecord {
65  readonly id: string
66  readonly loopId: LoopId
67  readonly tool: string
68  /** Tool plus key-sorted, normalised input minus cosmetic fields. */
69  readonly argsKey: string
70  /** Short human label: `npm test`, `Edit src/api/users.ts`. */
71  readonly label: string
72  readonly outcome: Outcome
73  /** Exact normalised outcome (keeps `file:line:col`); for repeats, cycles, polls. */
74  readonly outcomeFp: string
75  /** Coarse failure identity (line numbers stripped); failures only. */
76  readonly errorSig?: string
77  readonly errorClass?: ErrorClass
78  /** The token an environment error names, e.g. `pnpm` for `command not found: pnpm`. */
79  readonly errorToken?: string
80  /** Most specific error line, redacted and capped; failures only. */
81  readonly snippet?: string
82  /** Edit/Write: the file the call changed. */
83  readonly file?: string
84  /** Edit/Write that succeeded: content hash after the call, when computable. */
85  readonly fileHashAfter?: string
86  /** Edit/Write: content hash before the call, when known. */
87  readonly fileHashBefore?: string
88  /** Edit: hashes of the replaced and replacing text, for inverse-edit checks. */
89  readonly editPair?: readonly [string, string]
90  readonly isEdit: boolean
91  readonly isReadOnly: boolean
92  readonly isVerification: boolean
93  readonly isPoll: boolean
94  /** Bash that only moves between versions (`git checkout`, `stash`, `bisect`…). */
95  readonly isNavigation: boolean
96  /** A PreToolUse hook refused the call (recorded as `denied`): a procedural block, not a person's no. */
97  readonly isGuardBlock: boolean
98  readonly at: number
99  /** See `ToolObservation.startedAt`. */
100  readonly startedAt?: number
101}
102
103/** One successful content change of a file. */
104export interface FileEdit {
105  readonly id: string
106  /** Content hash before the change, when known. */
107  readonly before?: string
108  /** Content hash after the change, when computable. */
109  readonly after?: string
110  /** Edit only: `[hash(old_string), hash(new_string)]`. */
111  readonly pair?: readonly [string, string]
112}
113
114/** Edit history of one file within a loop's current scope. */
115export interface FileHistory {
116  /** Successful, content-changing edits in order (failed and no-op ones are skipped). */
117  readonly edits: readonly FileEdit[]
118  readonly touchedAt: number
119}
120
121/** One loop's detection window, since the last user prompt. */
122export interface LoopWindow {
123  readonly records: readonly CallRecord[]
124  readonly files: Readonly<Record<string, FileHistory>>
125  readonly touchedAt: number
126}
127
128/** When a nudged or escalated signature counts as resolved. */
129export type ResolveWhen =
130  | { readonly kind: `action-succeeds`; readonly argsKey: string }
131  | {
132      readonly kind: `error-changes`
133      readonly errorSig: string
134      readonly argsKeys: readonly string[]
135    }
136  | { readonly kind: `output-changes`; readonly argsKey: string; readonly outcomeFp: string }
137  | { readonly kind: `user-prompt` }
138
139/** A detector's finding: this signature has recurred `count` times. */
140export interface Signal {
141  readonly detector: DetectorId
142  /** `detector:key`, stable across reloads; the unit of nudging, ignoring and escalating. */
143  readonly signature: string
144  readonly loopId: LoopId
145  readonly count: number
146  readonly nudgeAt: number
147  readonly escalateAt: number
148  /** Call ids that make up the streak. */
149  readonly evidence: readonly string[]
150  /** What repeated, for people: `npm test`, `Edit src/a.ts`, `pnpm`. */
151  readonly label: string
152  readonly snippet?: string
153  readonly errorClass?: ErrorClass
154  readonly errorToken?: string
155  readonly resolveWhen: ResolveWhen
156}
157
158/**
159 * Where a signature stands on the ladder. `watching`: reached the nudge
160 * threshold but was not nudged (steering off, or the per-turn nudge budget
161 * spent), so it waits for the escalation threshold. `shadowed`: its evidence
162 * was already covered by another signature's nudge, so it stays silent.
163 */
164export type Stage = `nudged` | `watching` | `escalated` | `stopped` | `shadowed`
165
166/** The policy's memory of one signature. */
167export interface SignatureState {
168  readonly signature: string
169  readonly detector: DetectorId
170  readonly loopId: LoopId
171  readonly stage: Stage
172  readonly count: number
173  readonly wasNudged: boolean
174  readonly resolveWhen: ResolveWhen
175  readonly firstAt: number
176  readonly lastAt: number
177}
178
179/** Everything the policy remembers between calls. */
180export interface PolicyState {
181  readonly signatures: Readonly<Record<string, SignatureState>>
182  readonly nudgesThisTurn: number
183  /** Per loop, evidence already covered by a nudge or escalation. */
184  readonly covered: Readonly<Record<LoopId, readonly string[]>>
185  readonly ignored: readonly string[]
186  readonly isPaused: boolean
187}
188
189/** The whole detection state: what the adapter keeps in one `$.state` value. */
190export interface DetectionState {
191  readonly version: typeof DETECTION_STATE_VERSION
192  readonly loops: Readonly<Record<LoopId, LoopWindow>>
193  readonly policy: PolicyState
194}
195
196/** Copy for the user-facing band (Phase 5 draws it). */
197export interface AlertCopy {
198  readonly title: string
199  readonly detail: string
200  readonly hintTemplate: string
201}
202
203/** What the policy asks the adapter to do. */
204export type Decision =
205  | { readonly kind: `nudge`; readonly signal: Signal; readonly text: string }
206  | { readonly kind: `escalate`; readonly signal: Signal; readonly alert: AlertCopy }
207  | { readonly kind: `stop`; readonly signal: Signal }
208  | {
209      readonly kind: `resolve`
210      readonly signature: string
211      readonly detector: DetectorId
212      readonly loopId: LoopId
213      readonly wasNudged: boolean
214    }
215