# pi-loop-police

[![npm](https://img.shields.io/npm/v/pi-loop-police)](https://www.npmjs.com/package/pi-loop-police)

A [pi](https://pi.dev) extension that detects and breaks infinite loops in real time — before they waste your context window.

Reasoning models (especially small local ones like Qwen or DeepSeek) get stuck in characteristic ways: repeating the same phrases inside the thinking block, re-emitting the same paragraph in the visible answer, re-reading the same file over and over, or cycling through an identical sequence of tool calls until the context runs out. Loop Police watches for all of it **as it happens**: it aborts looping output mid-stream, trims the repetition out of your context, and injects a recovery message so the model continues with a fresh perspective — you keep the tokens the loop would have burned.

## Install

From npm:

```bash
pi install npm:pi-loop-police
```

Or from git:

```bash
pi install git:github.com/sebaxzero/pi-loop-police.git
```

Add `-l` to either form to install project-locally (adds to `.pi/settings.json` only).

No dependencies, no build step, nothing to configure — it starts protecting the session as soon as it loads. Everything below is optional tuning.

## What it detects

Ten detectors, all enabled out of the box:

| Detector | Fires when | What happens |
|----------|-----------|--------------|
| **Thinking loop** | the active thinking block ends in the same ≥ 80 chars twice in a row | stream aborted, signed reasoning sanitized, recovery message |
| **Semantic loop** | the same paragraph appears 3 times in the thinking block | same |
| **Output loop** | the visible answer ends in the same ≥ 100 chars twice in a row | same |
| **Output semantic loop** | the same paragraph appears 3 times in the visible answer | same |
| **Stagnation** | thinking across the last 4 turns is ≥ 85% similar | current reasoning sanitized; stagnant window scrubbed from future context |
| **File read ceiling** | the same file path is read 20 times total (only reads that actually ran count) | tool call blocked |
| **Redundant re-read** | ≥ 40% of the last 10 reads are re-reads of files unchanged since they were first read | tool call blocked in place |
| **Search spiral** | the same pattern is searched in 3 different locations | tool call blocked |
| **Tool call loop** | an identical sequence of tool calls repeats back-to-back | tool call blocked in place |
| **Re-derived reasoning** | right after any detection, the model's thinking re-derives the same reasoning that led to it (≥ 85% similar) | signed reasoning sanitized, recovery message |

### Streaming loops (thinking and output)

Both streams — the active block identified by Pi's streaming `contentIndex` — run the same two detectors as the text arrives, re-checked every 50 new characters (`STRIDE`):

- **Character-level**: fires when the text ends in two adjacent, verbatim copies of a block between `THINKING_WINDOW`/`OUTPUT_WINDOW` (80/100) and `MAX_WINDOW` (4000) characters — the model is re-emitting the same content word for word. Detection is a single O(length) pass, so it stays cheap even on very long streams.
- **Semantic**: every paragraph is fingerprinted by its first `FINGERPRINT_LEN` (60) characters; when the same fingerprint shows up `SEMANTIC_THRESHOLD` (3) times, the model is cycling through the same reasoning even if the wording drifts between passes or other text sits in between. Paragraphs inside ``` code fences are skipped — repeated code structure is legitimate, especially in answers.

The semantic layer is what catches loops early: repeats rarely stay perfectly verbatim, so the character-level check alone can take many extra cycles (or never fire if the repeating unit is huge). With both layers, a loop is typically caught on its third repetition regardless of how the wording mutates.

On detection the stream is aborted immediately. Contaminated thinking is replaced by an ordinary text marker with no `thinkingSignature` or redacted payload, preventing providers from replaying opaque reasoning that was supposedly removed. Output text is truncated at the detected boundary. A recovery message starts a new turn; repeated failures escalate through `CONSECUTIVE_LOOP_LIMIT`.

### Cross-turn stagnation

Some models never loop within a turn but still spin their wheels: each turn's thinking is a light rephrasing of the previous one. When the last `STAGNATION_WINDOW` (4) turns are all ≥ `STAGNATION_THRESHOLD` (85%) word-similar, the current reasoning is sanitized and a `context` gate removes every reasoning block in that stagnant window from later model requests while leaving the stored transcript available for postmortems.

### File read ceiling

Identical re-reads are the tool call sequence detector's job (below): reading the same file with the same arguments again is just a repeated tool call, blocked in place on the second back-to-back attempt. What remains for a dedicated file detector is the per-path complement: if a tool call looks like a file read (`read`, `view`, `cat`, …), `FILE_SCAN_LIMIT` (20) blocks once the same path has been read that many times **in total across all line ranges** — the model that keeps coming back to one file with ever-different offsets instead of searching it.

Only reads that **actually ran** count toward the ceiling. A call blocked by any detector never reached the file, so it does not spend the budget and never inflates the reported count. Raise the limit for workflows where heavy re-reading is legitimate, or run `/loop-police reset` to clear the counters mid-session. Note the redundant re-read window (next) judges re-reading more strictly: there, a re-read only counts as fresh when the file itself was edited in between.

### Redundant re-read window

The failure mode neither of the per-target counters can see: a model sweeping a repo (an audit, a review) loses track of which files it already read and starts re-reading them — interleaved with fresh reads, so no per-path total climbs toward the ceiling and no identical call sequence ever forms. The symptom in the thinking is generic ("Let me continue reading…") while progress has stopped.

`REREAD_WINDOW` (10) keeps a sliding window of the last real reads, marking each one **redundant** when its path was already read this session *and has not been written or edited since* — read → edit → re-read counts as fresh, because the edit invalidated what was in context. When the window is full and at least `REREAD_RATIO` (0.4 = 40%) of it is redundant, the read is blocked **in place**: the recovery message is handed back as the tool's result in the same turn, telling the model it already has that file, how many of its recent reads were repeats, and to work from context (or write down its progress) instead of re-reading. The window is cleared on a firing so blocks never chain back-to-back, and blocked reads never enter the window (they didn't run).

This deliberately treats *any* re-read of an unchanged file as redundant — including paging back into a big file or repeatedly consulting a reference file without editing it. If your workflow legitimately does that, raise `REREAD_RATIO`, or disable the detector with `REREAD_WINDOW=0`.

### Search expansion spiral

Tracks how many distinct paths each search pattern (`grep`, `glob`, `find`, …) has been applied to. A call that would reach `SEARCH_EXPAND_LIMIT` (3) locations is blocked in place and is not recorded as an executed search.

### Tool call sequence loop

Each tool call is hashed (`name` + arguments) into a history, and the extension checks whether the last *W* calls exactly repeat the *W* calls before them — any cycle length, not just single calls. On match, the repeated call is **blocked in place**: it does not run, and the recovery message is handed back as that tool's single result. All tool detectors use this one-message path; none adds a duplicate `sendMessage` recovery to context.

Because detection requires *adjacent* repetition, an interleaved different action breaks it: `build → edit → build` never trips, so legitimate re-runs after real changes are fine.

Two knobs adjust this detector:

- `TOOL_LOOP_BAN: 2` makes blocks **permanent per call** — once a specific call loops, that exact call stays blocked for the rest of the session (stronger against stubborn models, but it also blocks legitimate later re-runs). `1` (default) blocks only while the call is repeated back-to-back; `0` disables the detector.
- `TOOL_LOOP_EXEMPT` — comma-separated tool names (case-insensitive) that are never blocked, e.g. `"bash,run_tests"` for polling a build or re-running a flaky test. Exempt calls still enter the history, so they keep breaking adjacency for other tools.

### Re-derived reasoning guard

Blocking an action doesn't remove the reasoning that produced it. Larger models read a block message and pivot; small models re-read their own stale plan in context, arrive at the same conclusion, and try the exact same thing again — block, re-derive, retry, forever.

So after **any** detection fires, the guard watches the model's next message: if its thinking is ≥ `REDERIVE_THRESHOLD` (85%) Jaccard-similar, the entire reasoning block — including any opaque provider signature — is replaced with a minimal ordinary-text marker. A recovery message tells the model not to reconstruct it. The guard stays armed after a trim, so another re-derivation escalates to `⚠️ STUCK ({count}x)`.

This is the fix for the "stuck on reasoning loops" failure mode ([#8](https://github.com/sebaxzero/pi-loop-police/issues/8)): interrupting the *action* is not enough for small models — the reasoning itself has to go.

## Commands

```
/loop-police                          — show current detection state and all config values
/loop-police reset                    — clear all state (useful if a false positive fires)
/loop-police set KEY=VAL [KEY=VAL …]  — tune config values live, no restart needed
/loop-police save                     — write the current config to loop-police.json
```

Example: `/loop-police set FILE_SCAN_LIMIT=30 STAGNATION_WINDOW=5`

Assignments for string keys may contain spaces until the next `KEY=` token, so `/loop-police set HOOK_CMD=node /path/to/hook.mjs` stores the complete command. Numeric values are range-checked both here and when `loop-police.json` loads; invalid persisted values fall back to the corresponding default.

Changes made with `set` last for the session; `save` persists them to `loop-police.json` (see below).

## Configuration

Persistent configuration lives in `extensions/loop-police.json` next to the installed extension (auto-created on first load). You can ask the agent to edit it, or tune values live with `/loop-police set`.

```typescript
THINKING_WINDOW: 80         // char-level: shortest repeating block flagged in thinking
OUTPUT_WINDOW: 100          // char-level: shortest repeating block flagged in the response
MAX_WINDOW: 4000            // char-level: longest repeating block checked (both streams)
STRIDE: 50                  // re-run stream detection every N new characters
PARA_MIN_LEN: 40            // semantic: shorter paragraphs are ignored
FINGERPRINT_LEN: 60         // semantic: chars used as paragraph identity key
SEMANTIC_THRESHOLD: 3       // semantic: same fingerprint N times → loop (both streams)
STAGNATION_WINDOW: 4        // turns of similar thinking → stagnation
STAGNATION_THRESHOLD: 0.85  // similarity threshold for stagnation (Jaccard)
FILE_SCAN_LIMIT: 20         // real (non-blocked) reads of the same path before blocking
SEARCH_EXPAND_LIMIT: 3      // distinct paths for the same search pattern before blocking
REREAD_WINDOW: 10           // sliding window of real reads checked for redundancy
REREAD_RATIO: 0.4           // share of the window that is re-reads of unchanged files before blocking; **0 = fires on any redundant read (most aggressive), REREAD_WINDOW=0 to disable**
CONSECUTIVE_LOOP_LIMIT: 2   // looped turns in a row before the message escalates
TOOL_LOOP_BAN: 1            // 0 = off · 1 = block while repeated back-to-back · 2 = session ban
TOOL_LOOP_EXEMPT: ""        // tool names exempt from the tool call loop detector
REDERIVE_THRESHOLD: 0.85    // post-detection thinking this similar to the blocked plan is trimmed
HOOK_CMD: ""                // external command run on every detection (see Detection hooks)
HOOK_TIMEOUT_MS: 5000       // HOOK_CMD is killed after this many ms
HOOK_LOG: ""                // JSONL file appended on every detection (see Detection hooks)
```

Tuning rules of thumb:

- False positives on thinking/output loops → raise `THINKING_WINDOW`/`OUTPUT_WINDOW` (char-level) or `SEMANTIC_THRESHOLD`/`FINGERPRINT_LEN` (semantic).
- Structured answers with legitimately similar paragraph openings (checklists, per-file reports) → raise `FINGERPRINT_LEN` so fingerprints capture more of each paragraph.
- Projects where re-reading files is normal → raise `FILE_SCAN_LIMIT` (total per file); monorepos → raise `SEARCH_EXPAND_LIMIT`.
- Workflows that legitimately re-read unchanged files (huge files paged repeatedly, reference docs consulted often) → raise `REREAD_RATIO` (0.4 → 0.5–0.6) or set `REREAD_WINDOW=0`.
- Loops caught too late → lower `SEMANTIC_THRESHOLD` to 2 (more sensitive, more false-positive prone).

### Disabling individual detectors

Setting a detector's key to `0` turns it off entirely:

| Key = 0 | Disables |
|---------|----------|
| `THINKING_WINDOW=0` | character-level thinking loop |
| `OUTPUT_WINDOW=0` | character-level output loop |
| `SEMANTIC_THRESHOLD=0` | semantic loop (thinking **and** output) |
| `STAGNATION_WINDOW=0` | cross-turn stagnation |
| `FILE_SCAN_LIMIT=0` | file read ceiling |
| `SEARCH_EXPAND_LIMIT=0` | search expansion spiral |
| `REREAD_WINDOW=0` | redundant re-read window |
| `CONSECUTIVE_LOOP_LIMIT=0` | escalated consecutive-loop message |
| `TOOL_LOOP_BAN=0` | tool call sequence loop |
| `REDERIVE_THRESHOLD=0` | re-derived reasoning guard |

### Customizing recovery messages

The text injected when a loop is detected is configurable — some models respond better to different phrasing. These live alongside the numeric config in `loop-police.json` as `MSG_*` keys:

| Key | Fired when | Placeholders |
|-----|-----------|--------------|
| `MSG_THINKING_LOOP` | character-level thinking loop | — |
| `MSG_SEMANTIC_LOOP` | semantic thinking loop | — |
| `MSG_OUTPUT_LOOP` | character-level output loop | — |
| `MSG_OUTPUT_SEMANTIC_LOOP` | semantic output loop | — |
| `MSG_CONSECUTIVE_LOOP` | `CONSECUTIVE_LOOP_LIMIT` looped turns in a row | `{count}` |
| `MSG_STAGNATION` | cross-turn reasoning stagnation | `{window}` `{threshold}` |
| `MSG_FILE_SCAN_LOOP` | same file read too many times in total (all ranges) | `{path}` `{count}` |
| `MSG_SEARCH_SPIRAL` | search pattern spread across too many paths | `{pattern}` `{paths}` |
| `MSG_REREAD` | re-read of an unchanged, already-read file blocked | `{path}` `{count}` `{window}` |
| `MSG_TOOL_LOOP` | identical tool-call sequence repeating | `{windowSize}` |
| `MSG_REDERIVED` | post-detection reasoning re-derived and trimmed | — |
| `MSG_STUCK` | the same blocked plan re-derived `{count}` times in a row | `{count}` |
| `MSG_SUFFIX` | appended to **every** message above (empty by default) | — |

`{placeholder}` tokens are substituted at runtime; unknown tokens are left as-is so a typo stays visible. Messages are edited in `loop-police.json` only — `/loop-police set` handles numeric keys (plus `TOOL_LOOP_EXEMPT`) and will refuse a `MSG_*` key.

`MSG_SUFFIX` rides along with every detection without rewriting each template — the typical use is pointing the model at an advisor extension or tool to consult once a loop is caught:

```json
{
  "MSG_SUFFIX": "Before continuing, consult the advisor extension: run /advisor with a one-line summary of what you were stuck on."
}
```

## Detection hooks

Every detection also emits a structured payload to up to three observer channels, so you can build notifications, debugging artifacts, or analytics on top of loop-police without touching it. All three are purely observational: they can never block, delay, or alter detection and recovery.

The payload:

```json
{
  "event": "tool_loop",
  "timestamp": "2026-07-17T14:03:22.123Z",
  "model": { "id": "qwen3:14b", "name": "Qwen3 14B", "provider": "ollama" },
  "sessionId": "…",
  "sessionFile": "/path/to/session.jsonl",
  "cwd": "/path/to/project",
  "turnIndex": 12,
  "consecutiveLoops": 0,
  "details": { "toolName": "bash", "windowSize": 3, "banned": false }
}
```

`event` is one of `thinking_loop`, `semantic_loop`, `output_loop`, `output_semantic_loop`, `stagnation`, `file_scan_loop`, `redundant_reread`, `search_spiral`, `tool_loop`, `rederived_reasoning`. `model` is `null` when no model is selected. `details` is event-specific: the stream loops carry `{ stream, kind, escalated }` (`escalated: true` when the consecutive-loop message fired), `stagnation` carries `{ window, threshold }`, `file_scan_loop` `{ toolName, path, count }`, `redundant_reread` `{ toolName, path, count, window }`, `search_spiral` `{ toolName, pattern, paths }`, `tool_loop` `{ toolName, windowSize, banned }`, and `rederived_reasoning` `{ streak }`. The payload carries metadata only — never the thinking text or tool arguments; a hook that wants the transcript can read it from `sessionFile`.

### `HOOK_CMD` — run an external command

```
/loop-police set HOOK_CMD=node /path/to/hook.mjs
```

The assignment parser preserves `node /path/to/hook.mjs` as one `HOOK_CMD` value. The command runs fire-and-forget on every detection with the JSON payload as its **last argument**, and is killed after `HOOK_TIMEOUT_MS` (5000). It is spawned directly without a shell and then split into executable + fixed arguments; paths containing spaces are not supported. Exit code and output are ignored, but a failing hook shows a one-time warning per session.

Any language works:

```bash
#!/bin/bash                       # notify.sh — HOOK_CMD=/path/to/notify.sh
event=$(echo "$1" | jq -r .event)
notify-send "loop-police" "$event detected"
```

[`examples/hook.mjs`](examples/hook.mjs) is a ready-to-use hook that shows a desktop notification using only what the OS ships — a Windows toast, macOS `osascript`, or Linux `notify-send`; no npm packages, no external services. Copy it and point `HOOK_CMD` at it. Optionally set `LOOP_POLICE_NTFY_TOPIC` to also push to a phone via [ntfy.sh](https://ntfy.sh).

### `HOOK_LOG` — statistics with zero code

```
/loop-police set HOOK_LOG=/home/user/.pi/loop-stats.jsonl
```

Appends one payload line per detection (relative paths resolve against the session cwd). Point every project at the same absolute path and the answer to "which model and which detector fire the most" is a one-liner:

```bash
jq -r '"\(.model.id // "?") \(.event)"' loop-stats.jsonl | sort | uniq -c | sort -rn
```

`sessionId` and `turnIndex` in each line let you trace any detection back to the exact chat and turn; `sessionFile` points at the full transcript.

### `loop-police:detection` — the extension event bus

Other pi extensions can subscribe in-process, no config needed — loop-police always emits on pi's shared bus:

```typescript
pi.events.on("loop-police:detection", (payload) => { /* … */ });
```

This is the richest integration point: a listening extension has the full `ExtensionAPI`, so it can switch models, show UI, or send messages — things an external process cannot. [`examples/listener-extension.ts`](examples/listener-extension.ts) is a complete example that keeps a per-session loop counter in the status bar; copy it into your pi extensions directory to use it.

### Examples

- [pi-input-bar](https://github.com/sebaxzero/pi-input-bar) — subscribes to `loop-police:detection` and shows a per-session summary in the input editor's top border (`⚠ 10 loops (tool 7, think 3)`), grouped by detector family. Nothing is shown until a loop is caught, and the subscription is a no-op when loop-police isn't installed — a good template for optional integrations.

## Skills

Two skills ship with the extension:

- **loop-police-help** — reference card: commands, config keys, and where the persistent `loop-police.json` lives for each install type.
- **loop-police-postmortem** — asks the agent to analyze the loop-police detections in the current session: reconstruct what triggered each firing, classify it (justified / false positive / justified-but-ineffective), and recommend config changes where tuning could have avoided it. Trigger it with things like *"why did loop-police fire?"*, *"was that a false positive?"*, or *"do a loop post-mortem"*.

## Upgrading

Config migrations are automatic — your customized values are preserved and the file is re-stamped, once:

- **From < 1.12.0**: the same-range file read detector was removed — identical re-reads are blocked by the tool call sequence detector instead, and the ceiling now counts only reads that actually ran. `FILE_READ_LIMIT` and `MSG_FILE_READ_LOOP` are dropped from stored configs; a `FILE_SCAN_LIMIT` you never customized picks up the new default (15 → 20).
- **From < 1.8.0**: the stream-detector keys were renamed (`MIN_THINKING_WINDOW` → `THINKING_WINDOW`, `MIN_OUTPUT_WINDOW` → `OUTPUT_WINDOW`, `MAX_THINKING_WINDOW` → `MAX_WINDOW`, `CHECK_STRIDE` → `STRIDE`, `PARA_FINGERPRINT_LEN` → `FINGERPRINT_LEN`, `PARA_LOOP_THRESHOLD` → `SEMANTIC_THRESHOLD`). Customized values are carried over to the new names; values you never touched pick up the new defaults (notably `MAX_WINDOW` grew from 2000 to 4000).
- **From < 1.5.0**: the `TOOL_LOOP_BAN` scale shifted by one (old `0` = temporary → new `1`, old `1` = permanent → new `2`; `0` now means off). The stored value is bumped to preserve the behavior you had.

## Compatibility

Designed for OpenAI-compatible reasoning models (Qwen3, DeepSeek-R1, etc.) used via pi. Pi normalizes all provider thinking formats to `{ type: "thinking", thinking: string }` content blocks, so this extension works regardless of the underlying provider.

Works alongside [pi-canary](https://github.com/sebaxzero/pi-canary), which silently verifies agent context awareness using hidden canary tokens. When loop-police aborts a turn, pi-canary yields gracefully and does not fire its own recovery.

## License

MIT
