pi-anti-doom-loop 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 irfandi m (join.mantap@gmail.com) / github.com/irfndi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # pi-anti-doom-loop
2
+
3
+ Stop agent doom loops in [pi](https://pi.dev/) before they burn tokens.
4
+
5
+ Cheap models sometimes get stuck repeating the same cheap tool call — `grep`
6
+ the same file, re-run the same failing command — with no progress. Each
7
+ iteration is so cheap nobody notices until the bill mounts. This extension
8
+ watches every tool call and blocks the loop at the source.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pi install npm:pi-anti-doom-loop
14
+ ```
15
+
16
+ ## What it detects
17
+
18
+ | Signal | Default | Blocked when |
19
+ | ------------------------------- | ----------------------- | --------------------------------------------------------------- |
20
+ | Same `(tool, args)` repeated | 3× in the last 10 calls | The pattern has repeated `3` times with no change |
21
+ | Same tool failing consecutively | 3× | A tool errored `3` times in a row — stop retrying it blindly |
22
+ | Same assistant text verbatim | 3× in a row | The model re-emitted identical text `3` times (text-only loops) |
23
+
24
+ Blocks hand the model an instructive reason ("change your approach, use a
25
+ different tool, or ask the user"). If the model ignores the block and re-issues
26
+ the exact same call, the turn is **aborted** and you are notified. A verbatim
27
+ text loop (no tool calls involved) aborts the run immediately with a
28
+ notification.
29
+
30
+ Counters reset on every user prompt, so a task legitimately repeated later in
31
+ the same session is never a false positive.
32
+
33
+ ## Configuration
34
+
35
+ Environment variables, read at session/prompt start:
36
+
37
+ | Variable | Default | Meaning |
38
+ | --------------------------- | ------- | -------------------------------------------------- |
39
+ | `PI_ANTI_LOOP_REPEATS` | `3` | Identical-call block threshold |
40
+ | `PI_ANTI_LOOP_FAILS` | `3` | Consecutive-failure block threshold |
41
+ | `PI_ANTI_LOOP_TEXT_REPEATS` | `3` | Consecutive identical assistant texts before abort |
42
+ | `PI_ANTI_LOOP_WINDOW` | `10` | How many recent calls/results are inspected |
43
+ | `PI_ANTI_LOOP_DISABLE` | — | Set to `1` to disable the extension entirely |
44
+
45
+ ## Command
46
+
47
+ - `/loopcheck` — show current thresholds and counters
48
+ - `/loopcheck reset` — clear counters
49
+
50
+ ## How it works
51
+
52
+ Everything hooks into the `tool_call` / `tool_result` events; detection is a
53
+ small sliding-window counter (see `extensions/detector.ts`) with no state kept
54
+ between user prompts. Works with any model — cheap models just trigger it more
55
+ often.
56
+
57
+ ## Development
58
+
59
+ Requires Node 22.6+ (plain `node` runs the TS self-check).
60
+
61
+ ```bash
62
+ npm install
63
+ npm test # detector self-check (pure Node, no deps)
64
+ npm run check # npm test + tsc + oxlint --deny-warnings + oxfmt
65
+ ```
66
+
67
+ Runtime deps: `better-result` (detector decisions) and `effect` v4 (the release guard).
68
+
69
+ > `peerDependencies` pins `@earendil-works/pi-coding-agent` at `"*"` on purpose — the
70
+ > [pi packages docs](https://pi.dev/docs/latest/packages) require an unbounded range for
71
+ > pi-core packages (pi provides them at runtime). The extension loads `.ts` directly via
72
+ > jiti, so no build step ships; `prepublishOnly` runs the full quality gate before publish.
73
+
74
+ > When a call is blocked, escalation still works without recording it in the window:
75
+ > re-issuing the identical call increments a per-signature block counter and aborts the
76
+ > turn on the second block. Thresholds are clamped to a minimum of 2 so a bad config
77
+ > can never brick the agent.
78
+
79
+ ## Releasing
80
+
81
+ Publishing is handled by the GitHub Actions workflow [`.github/workflows/release.yml`](.github/workflows/release.yml), guarded against version drift:
82
+
83
+ 1. Add an npm **Automation** token as the `NPM_TOKEN` repo secret (Settings → Secrets and variables → Actions, or `gh secret set NPM_TOKEN`).
84
+ 2. Bump `version` in `package.json`, commit, then tag and push:
85
+
86
+ ```bash
87
+ git tag v0.0.1
88
+ git push origin v0.0.1
89
+ ```
90
+
91
+ CI runs the quality gate, then the **version bump guard** (`npm run guard`): it blocks publishing if the version is already on npm or the tag doesn't match `package.json`. After the first publish, the [pi.dev gallery](https://pi.dev/packages) picks the package up automatically via the `pi-package` keyword.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Pure loop-detection logic — no pi imports, so it is runnable/testable
3
+ * standalone (`node extensions/detector.ts` self-check at the bottom).
4
+ *
5
+ * Detects two "doom loop" signatures, both scoped to a sliding window so a
6
+ * genuinely repeated task spread over time never trips it:
7
+ *
8
+ * 1. The same tool called with identical arguments `repeatThreshold` times
9
+ * (the classic "grep the same file 10 times" loop).
10
+ * 2. The same tool failing `failThreshold` consecutive times (blind retry
11
+ * of a flaky/ungrounded operation).
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { Result } from "better-result";
15
+
16
+ export interface LoopOptions {
17
+ /** Identical (tool, args) occurrences that trigger a block. */
18
+ repeatThreshold: number;
19
+ /** Consecutive same-tool errors that trigger a block. */
20
+ failThreshold: number;
21
+ /** How many recent calls/results are inspected for repetition. */
22
+ windowSize: number;
23
+ /** Consecutive verbatim assistant messages that trigger an abort. */
24
+ textRepeatThreshold: number;
25
+ }
26
+
27
+ export const DEFAULT_OPTIONS: LoopOptions = {
28
+ repeatThreshold: 3,
29
+ failThreshold: 3,
30
+ windowSize: 10,
31
+ textRepeatThreshold: 3,
32
+ };
33
+
34
+ export function readOptions(env: Record<string, string | undefined> = process.env): LoopOptions {
35
+ // ponytail: min 2 — a threshold of 1 would block every tool call / abort on
36
+ // the first message, bricking the agent (deepsec finding).
37
+ const num = (key: string, fallback: number): number => {
38
+ const raw = env[key];
39
+ if (!raw) return fallback;
40
+ const n = Number(raw);
41
+ return Number.isFinite(n) && n >= 2 ? n : fallback;
42
+ };
43
+ return {
44
+ repeatThreshold: num("PI_ANTI_LOOP_REPEATS", DEFAULT_OPTIONS.repeatThreshold),
45
+ failThreshold: num("PI_ANTI_LOOP_FAILS", DEFAULT_OPTIONS.failThreshold),
46
+ windowSize: num("PI_ANTI_LOOP_WINDOW", DEFAULT_OPTIONS.windowSize),
47
+ textRepeatThreshold: num("PI_ANTI_LOOP_TEXT_REPEATS", DEFAULT_OPTIONS.textRepeatThreshold),
48
+ };
49
+ }
50
+
51
+ /** Keys sorted recursively so {a:1,b:2} and {b:2,a:1} share a signature. */
52
+ export function canonical(input: unknown): string {
53
+ if (input === null || typeof input !== "object") return JSON.stringify(input);
54
+ if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`;
55
+ const obj = input as Record<string, unknown>;
56
+ return `{${Object.keys(obj)
57
+ .sort()
58
+ .map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`)
59
+ .join(",")}}`;
60
+ }
61
+
62
+ export function signature(toolName: string, input: unknown): string {
63
+ return `${toolName}:${canonical(input)}`;
64
+ }
65
+
66
+ export interface BlockDecision {
67
+ /** Reason shown to the LLM as the (blocked) tool result. */
68
+ reason: string;
69
+ /** True when this exact call was already blocked before — caller should abort the turn. */
70
+ escalate: boolean;
71
+ }
72
+
73
+ /**
74
+ * Call `check` in `tool_call` (before executing). If it returns a decision,
75
+ * block the call. Call `record` only for calls that were NOT blocked, and
76
+ * `recordResult` in `tool_result` for executed calls.
77
+ */
78
+ export class LoopDetector {
79
+ readonly opts: LoopOptions;
80
+ private recentSigs: string[] = [];
81
+ private recentResults: { tool: string; error: boolean }[] = [];
82
+ private blockedBySig = new Map<string, number>();
83
+ private lastText: string | null = null;
84
+ private textStreak = 0;
85
+ private textFired = false;
86
+
87
+ constructor(opts: LoopOptions = DEFAULT_OPTIONS) {
88
+ this.opts = opts;
89
+ }
90
+
91
+ check(toolName: string, input: unknown): Result<BlockDecision, undefined> {
92
+ const sig = signature(toolName, input);
93
+ const repeats = this.recentSigs.filter((s) => s === sig).length;
94
+ const total = repeats + 1; // including this call
95
+ const fails = this.consecutiveFails(toolName);
96
+
97
+ if (total < this.opts.repeatThreshold && fails < this.opts.failThreshold)
98
+ return Result.err(undefined);
99
+
100
+ const reasons: string[] = [];
101
+ if (total >= this.opts.repeatThreshold) {
102
+ reasons.push(
103
+ `"${toolName}" was called with identical arguments ${total} times in the last ${this.opts.windowSize} tool calls with no change`,
104
+ );
105
+ }
106
+ if (fails >= this.opts.failThreshold) {
107
+ reasons.push(`"${toolName}" failed ${fails} consecutive times`);
108
+ }
109
+
110
+ const blockedCount = (this.blockedBySig.get(sig) ?? 0) + 1;
111
+ this.blockedBySig.set(sig, blockedCount);
112
+
113
+ return Result.ok({
114
+ reason:
115
+ reasons.join("; ") +
116
+ `. BLOCKED by anti-doom-loop — you appear to be looping. Change your approach, use a different tool, or ask the user.`,
117
+ escalate: blockedCount > 1,
118
+ });
119
+ }
120
+
121
+ record(toolName: string, input: unknown): void {
122
+ this.recentSigs.push(signature(toolName, input));
123
+ if (this.recentSigs.length > this.opts.windowSize) this.recentSigs.shift();
124
+ }
125
+
126
+ recordResult(toolName: string, error: boolean): void {
127
+ this.recentResults.push({ tool: toolName, error });
128
+ if (this.recentResults.length > this.opts.windowSize) this.recentResults.shift();
129
+ }
130
+
131
+ /**
132
+ * Consecutive verbatim assistant text (whitespace-normalized). Fires once
133
+ * per run when the streak reaches textRepeatThreshold, then stays silent
134
+ * until reset — message_end cannot return a block, so the caller aborts.
135
+ * $
136
+ * Detects the text-only loop shape (model re-emits the same sentence
137
+ * forever, e.g. goal-function loops) that identical-tool-call detection
138
+ * never sees. Liquid.ai's Antidoom mines loops as 'a section repeats at
139
+ * least four times'; we use 3 and no length floor because at runtime each
140
+ * repetition already burns tokens.
141
+ */
142
+ checkText(text: string): Result<{ reason: string }, undefined> {
143
+ const norm = normalizeText(text);
144
+ if (!norm) return Result.err(undefined); // blank text is not a loop signal
145
+ this.textStreak = norm === this.lastText ? this.textStreak + 1 : 1;
146
+ this.lastText = norm;
147
+ if (this.textStreak >= this.opts.textRepeatThreshold && !this.textFired) {
148
+ this.textFired = true;
149
+ return Result.ok({
150
+ reason:
151
+ `Assistant replied with identical text ${this.textStreak} times in a row ("${truncate(norm, 80)}"). ` +
152
+ `You appear to be in a loop — this run is aborted.`,
153
+ });
154
+ }
155
+ return Result.err(undefined);
156
+ }
157
+
158
+ /** Trail of results that are errors of this same tool (consecutive). */
159
+ private consecutiveFails(toolName: string): number {
160
+ let n = 0;
161
+ for (let i = this.recentResults.length - 1; i >= 0; i--) {
162
+ const r = this.recentResults[i];
163
+ if (!r.error || r.tool !== toolName) break;
164
+ n++;
165
+ }
166
+ return n;
167
+ }
168
+
169
+ reset(): void {
170
+ this.recentSigs = [];
171
+ this.recentResults = [];
172
+ this.blockedBySig.clear();
173
+ this.lastText = null;
174
+ this.textStreak = 0;
175
+ this.textFired = false;
176
+ }
177
+
178
+ summary(): string {
179
+ const blocked = [...this.blockedBySig.values()].reduce((a, b) => a + b, 0);
180
+ const text = this.textStreak > 1 ? `, text streak ${this.textStreak}` : "";
181
+ return `window: ${this.recentSigs.length}/${this.opts.windowSize} calls, ${this.recentResults.length} results, blocked ${blocked} time(s)${text}`;
182
+ }
183
+ }
184
+
185
+ /** Collapse runs of whitespace so formatting drift never hides a verbatim loop. */
186
+ export function normalizeText(text: string): string {
187
+ return text.trim().replace(/\s+/g, " ");
188
+ }
189
+
190
+ /** First `max` chars of a single-line string, with an ellipsis. */
191
+ export function truncate(text: string, max: number): string {
192
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
193
+ }
194
+
195
+ // --- self-check (runs under `node extensions/detector.ts`, skipped when loaded by pi) ---
196
+ if (import.meta.main) {
197
+ const opts: LoopOptions = {
198
+ repeatThreshold: 3,
199
+ failThreshold: 3,
200
+ windowSize: 10,
201
+ textRepeatThreshold: 3,
202
+ };
203
+ const d: LoopDetector = new LoopDetector(opts);
204
+
205
+ // 1. identical calls: 2 pass, 3rd is blocked; retry of a blocked call escalates
206
+ assert.ok(d.check("bash", { command: "grep foo bar.ts" }).isErr(), "1st call passes");
207
+ d.record("bash", { command: "grep foo bar.ts" });
208
+ assert.ok(d.check("bash", { command: "grep foo bar.ts" }).isErr(), "2nd call passes");
209
+ d.record("bash", { command: "grep foo bar.ts" });
210
+ const hit = d.check("bash", { command: "grep foo bar.ts" });
211
+ assert.ok(hit.isOk(), "3rd identical call should block");
212
+ if (hit.isOk()) {
213
+ assert.equal(hit.value.escalate, false, "first block does not escalate");
214
+ assert.match(hit.value.reason, /identical arguments 3 times/);
215
+ }
216
+ // LLM ignores the block and retries the exact same call: escalate
217
+ const hit2 = d.check("bash", { command: "grep foo bar.ts" });
218
+ assert.ok(hit2.isOk() && hit2.value.escalate, "retry of a blocked call should escalate");
219
+
220
+ // 2. different arguments are not a loop
221
+ d.reset();
222
+ assert.ok(d.check("read", { path: "a.ts" }).isErr());
223
+ d.record("read", { path: "a.ts" });
224
+ assert.ok(d.check("read", { path: "b.ts" }).isErr());
225
+ d.record("read", { path: "b.ts" });
226
+ assert.ok(d.check("read", { path: "a.ts" }).isErr(), "arg order/counter: only same args count");
227
+
228
+ // 3. key order does not matter
229
+ assert.equal(signature("read", { a: 1, b: 2 }), signature("read", { b: 2, a: 1 }));
230
+
231
+ // 4. consecutive same-tool failures trigger a block
232
+ d.reset();
233
+ d.recordResult("bash", true);
234
+ d.recordResult("bash", true);
235
+ d.recordResult("bash", true);
236
+ const failHit = d.check("bash", { command: "npm test" });
237
+ assert.ok(failHit.isOk(), "3 consecutive failures should block");
238
+ if (failHit.isOk()) assert.match(failHit.value.reason, /failed 3 consecutive times/);
239
+
240
+ // 5. an interleaved success breaks the failure streak
241
+ d.reset();
242
+ d.recordResult("bash", true);
243
+ d.recordResult("bash", false);
244
+ d.recordResult("bash", true);
245
+ d.recordResult("bash", true);
246
+ assert.ok(d.check("bash", { command: "npm test" }).isErr(), "success breaks the streak");
247
+
248
+ // 6. window eviction: stale repeats no longer count
249
+ d.reset();
250
+ for (let i = 0; i < opts.windowSize; i++) d.record("bash", { command: `cmd ${i}` });
251
+ assert.ok(d.check("bash", { command: "cmd 0" }).isErr(), "evicted repeats do not count");
252
+
253
+ // 7. verbatim assistant text loop: fires once at the 3rd identical message
254
+ d.reset();
255
+ assert.ok(d.checkText("Now update buildProgram.").isErr(), "1st text passes");
256
+ assert.ok(d.checkText("Now update buildProgram.").isErr(), "2nd text passes");
257
+ const textHit = d.checkText("Now update buildProgram.");
258
+ assert.ok(textHit.isOk(), "3rd identical text should fire");
259
+ if (textHit.isOk()) {
260
+ assert.match(textHit.value.reason, /identical text 3 times/);
261
+ assert.match(textHit.value.reason, /aborted/);
262
+ }
263
+ assert.ok(d.checkText("Now update buildProgram.").isErr(), "fires only once per run");
264
+
265
+ // 8. whitespace drift does not hide a verbatim loop
266
+ d.reset();
267
+ d.checkText("Read the region:");
268
+ d.checkText("Read the region:");
269
+ const wsHit = d.checkText(" Read the region: ");
270
+ assert.ok(wsHit.isOk(), "whitespace-normalized repeats should fire");
271
+
272
+ // 9. a different message breaks the streak (need 3 consecutive AFTER the break to fire)
273
+ d.reset();
274
+ d.checkText("A");
275
+ d.checkText("A");
276
+ d.checkText("B"); // breaks the streak
277
+ d.checkText("A");
278
+ assert.ok(d.checkText("A").isErr(), "only 2 consecutive As after the break must not fire");
279
+ const again = d.checkText("A");
280
+ assert.ok(again.isOk(), "3 consecutive As after the break fire");
281
+
282
+ // 10. empty/blank text is ignored as a loop signal
283
+ d.reset();
284
+ d.checkText("");
285
+ d.checkText(" ");
286
+ assert.ok(d.checkText("").isErr(), "blank text must not fire");
287
+
288
+ // 11. threshold 1 is clamped away (would brick the agent)
289
+ const clamped = readOptions({
290
+ PI_ANTI_LOOP_REPEATS: "1",
291
+ PI_ANTI_LOOP_FAILS: "0",
292
+ PI_ANTI_LOOP_TEXT_REPEATS: "-2",
293
+ });
294
+ assert.equal(clamped.repeatThreshold, 3, "1 falls back to default");
295
+ assert.equal(clamped.failThreshold, 3, "0 falls back to default");
296
+ assert.equal(clamped.textRepeatThreshold, 3, "negative falls back to default");
297
+ const two = readOptions({ PI_ANTI_LOOP_REPEATS: "2" });
298
+ assert.equal(two.repeatThreshold, 2, "2 is the minimum accepted");
299
+
300
+ console.log("detector self-check: all assertions passed");
301
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * anti-doom-loop — pi extension that detects and breaks agent doom loops.
3
+ *
4
+ * Cheap models sometimes repeat the same cheap tool call (grep, read, ls)
5
+ * without progress, silently burning tokens. This extension watches every
6
+ * tool call and blocks loops before they cost anything:
7
+ *
8
+ * - identical (tool, args) repeated `PI_ANTI_LOOP_REPEATS` times (default 3)
9
+ * in the last `PI_ANTI_LOOP_WINDOW` calls → block with an instructive reason
10
+ * - the same tool failing `PI_ANTI_LOOP_FAILS` consecutive times (default 3)
11
+ * → block with a "stop retrying, fix the root cause" reason
12
+ *
13
+ * Blocking hands control back to the model once. If the model re-issues the
14
+ * exact same blocked call, the turn is aborted (escalation).
15
+ *
16
+ * Counters reset on every user prompt, so a task legitimately repeated later
17
+ * in the session is never a false positive. Disable with PI_ANTI_LOOP_DISABLE=1.
18
+ */
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { LoopDetector, readOptions } from "./detector";
21
+
22
+ export default function (pi: ExtensionAPI): void {
23
+ if (process.env.PI_ANTI_LOOP_DISABLE === "1") return;
24
+
25
+ let detector = new LoopDetector(readOptions());
26
+ let configTxt = settingsTxt(detector);
27
+ const blockedIds = new Set<string>();
28
+
29
+ pi.on("session_start", () => reset());
30
+
31
+ // Fresh counters per user prompt: only the loop happening *right now* counts.
32
+ pi.on("before_agent_start", () => reset());
33
+
34
+ pi.on("tool_call", (event, ctx) => {
35
+ const decision = detector.check(event.toolName, event.input);
36
+ if (decision.isErr()) {
37
+ detector.record(event.toolName, event.input);
38
+ return;
39
+ }
40
+ const block = decision.value;
41
+ blockedIds.add(event.toolCallId);
42
+ if (block.escalate) {
43
+ ctx.ui.notify("Anti-doom-loop: identical call blocked again — aborting turn", "error");
44
+ ctx.abort();
45
+ }
46
+ return { block: true, reason: block.reason };
47
+ });
48
+
49
+ // Blocked calls never ran, so their (error) result must not count as a failure.
50
+ pi.on("tool_result", (event) => {
51
+ if (blockedIds.has(event.toolCallId)) {
52
+ blockedIds.delete(event.toolCallId);
53
+ return;
54
+ }
55
+ detector.recordResult(event.toolName, event.isError === true);
56
+ });
57
+
58
+ // Text-only doom loops (model re-emits the same sentence with no tool calls)
59
+ // never reach tool_call. Detect verbatim assistant repeats and abort the run.
60
+ pi.on("message_end", (event, ctx) => {
61
+ const message = event.message;
62
+ if (message.role !== "assistant") return;
63
+ const content = Array.isArray(message.content) ? message.content : [];
64
+ const text = content
65
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
66
+ .map((c) => c.text)
67
+ .join(" ");
68
+ if (!text) return;
69
+ const hit = detector.checkText(text);
70
+ if (hit.isOk()) {
71
+ ctx.ui.notify(`Anti-doom-loop: ${hit.value.reason}`, "error");
72
+ ctx.abort();
73
+ }
74
+ });
75
+
76
+ pi.registerCommand("loopcheck", {
77
+ description: "Anti-doom-loop status; `/loopcheck reset` clears counters",
78
+ handler: async (args, ctx) => {
79
+ if (args.trim().toLowerCase() === "reset") {
80
+ reset();
81
+ ctx.ui.notify("Anti-doom-loop: counters reset", "info");
82
+ return;
83
+ }
84
+ ctx.ui.notify(`${configTxt}. ${detector.summary()}`, "info");
85
+ },
86
+ });
87
+
88
+ function reset(): void {
89
+ detector = new LoopDetector(readOptions());
90
+ configTxt = settingsTxt(detector);
91
+ blockedIds.clear();
92
+ }
93
+ }
94
+
95
+ function settingsTxt(d: LoopDetector): string {
96
+ const o = d.opts;
97
+ return `anti-doom-loop: repeats>=${o.repeatThreshold}/window ${o.windowSize}, fails>=${o.failThreshold}`;
98
+ }
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "pi-anti-doom-loop",
3
+ "version": "0.0.1",
4
+ "description": "Detect and break agent doom loops in pi: blocks identical repeated tool calls and blind retries before they burn tokens.",
5
+ "keywords": [
6
+ "anti-doom-loop",
7
+ "loop-detection",
8
+ "pi-extension",
9
+ "pi-package"
10
+ ],
11
+ "homepage": "https://github.com/irfndi/pi-anti-doom-loop",
12
+ "license": "MIT",
13
+ "author": "irfandi <join.mantap@gmail.com> (https://github.com/irfndi)",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/irfndi/pi-anti-doom-loop.git"
17
+ },
18
+ "files": [
19
+ "extensions",
20
+ "scripts"
21
+ ],
22
+ "type": "module",
23
+ "main": "extensions/index.ts",
24
+ "types": "extensions/index.ts",
25
+ "exports": {
26
+ ".": "./extensions/index.ts"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "check": "npm run test && tsc --noEmit && oxlint --deny-warnings && oxfmt --check .",
33
+ "prepublishOnly": "npm run check",
34
+ "test": "node extensions/detector.ts",
35
+ "guard": "node scripts/guard-publish.ts",
36
+ "lint": "oxlint --deny-warnings",
37
+ "format": "oxfmt .",
38
+ "format:check": "oxfmt --check ."
39
+ },
40
+ "dependencies": {
41
+ "better-result": "^3.0.0",
42
+ "effect": "^4.0.0-beta.103"
43
+ },
44
+ "devDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "@types/node": "^22.0.0",
47
+ "oxfmt": "^0.62.0",
48
+ "oxlint": "^1.77.0",
49
+ "typescript": "^5.6.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": "*"
53
+ },
54
+ "engines": {
55
+ "node": ">=22.18"
56
+ },
57
+ "pi": {
58
+ "extensions": [
59
+ "./extensions"
60
+ ]
61
+ }
62
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Version bump guard — the consistency gate before `npm publish`.
3
+ *
4
+ * Fails (exit 1) unless BOTH hold:
5
+ * 1. The package.json version is NOT already the latest published on npm
6
+ * (npm never overwrites a published version; re-releasing the same
7
+ * version is a no-op at best and a dishonest release at worst).
8
+ * 2. If an expected version is passed (e.g. a tag like v0.0.1), it matches
9
+ * the package.json version — so tags and manifests can't drift.
10
+ *
11
+ * Implemented with Effect v4: each fallible step (read, parse, fetch, guard
12
+ * rules) lives in the typed error channel, so failures short-circuit with a
13
+ * clean GUARD FAIL message and exit code 1.
14
+ *
15
+ * Usage:
16
+ * node scripts/guard-publish.ts # keep published version < local
17
+ * node scripts/guard-publish.ts v0.0.1 # also require local == 0.0.1
18
+ */
19
+ import { Effect } from "effect";
20
+ import { readFile } from "node:fs/promises";
21
+
22
+ interface GuardError {
23
+ message: string;
24
+ }
25
+
26
+ interface Manifest {
27
+ name: string;
28
+ version: string;
29
+ }
30
+
31
+ const fail = (message: string): Effect.Effect<never, GuardError> => Effect.fail({ message });
32
+
33
+ const readManifest: Effect.Effect<string, GuardError> = Effect.tryPromise({
34
+ try: () => readFile(new URL("../package.json", import.meta.url), "utf8"),
35
+ catch: () => ({ message: "GUARD FAIL: could not read package.json" }),
36
+ });
37
+
38
+ const parseManifest = (raw: string): Effect.Effect<Manifest, GuardError> => {
39
+ let manifest: Manifest;
40
+ try {
41
+ manifest = JSON.parse(raw) as Manifest;
42
+ } catch {
43
+ return Effect.fail({ message: "GUARD FAIL: package.json is not valid JSON" });
44
+ }
45
+ return Effect.succeed(manifest);
46
+ };
47
+ const semverLike = (value: string): boolean => /^\d+\.\d+\.\d+$/.test(value);
48
+
49
+ /**
50
+ * Whether a git tag `v<version>` exists in the current checkout. On the
51
+ * manual-dispatch path no tag is pushed, so this closes the tag/sync gap.
52
+ */
53
+ const tagExists = (version: string): Effect.Effect<boolean> =>
54
+ Effect.tryPromise({
55
+ try: async () => {
56
+ const { execFile } = await import("node:child_process");
57
+ const { promisify } = await import("node:util");
58
+ const run = promisify(execFile);
59
+ try {
60
+ const { stdout } = await run("git", ["tag", "-l", `v${version}`], { cwd: process.cwd() });
61
+ return stdout.trim().length > 0;
62
+ } catch {
63
+ return false; // no git repo / git missing → treat as no tag
64
+ }
65
+ },
66
+ catch: () => new Error("git tag check failed"),
67
+ }).pipe(Effect.catch(() => Effect.succeed(false)));
68
+ /**
69
+ * Latest published version on npm, or null when the package was never
70
+ * published or the registry is unreachable (warn-only on network failure —
71
+ * `npm publish` remains the real gate).
72
+ */
73
+ const fetchPublished = (name: string): Effect.Effect<string | null> =>
74
+ Effect.tryPromise({
75
+ try: async () => {
76
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, {
77
+ headers: { accept: "application/json" },
78
+ });
79
+ if (!res.ok) return null;
80
+ return ((await res.json()) as { version?: string }).version ?? null;
81
+ },
82
+ catch: () => {
83
+ console.warn(
84
+ "GUARD WARN: could not reach the npm registry; skipping published-version check.",
85
+ );
86
+ return new Error("registry unreachable");
87
+ },
88
+ }).pipe(Effect.catch(() => Effect.succeed(null)));
89
+
90
+ const program: Effect.Effect<string, GuardError> = Effect.gen(function* () {
91
+ const raw = yield* readManifest;
92
+ const pkg = yield* parseManifest(raw);
93
+ const local = pkg.version;
94
+ const expected = (process.argv[2] ?? "").replace(/^v/, ""); // tolerate "v0.0.1"
95
+
96
+ if (!semverLike(local)) {
97
+ yield* fail(`GUARD FAIL: package.json version "${local}" is not a semver X.Y.Z.`);
98
+ }
99
+ if (expected && expected !== local) {
100
+ yield* fail(
101
+ `GUARD FAIL: expected "${expected}" (tag/input) does not match package.json version "${local}". ` +
102
+ `Bump package.json to ${expected}, or tag ${local}.`,
103
+ );
104
+ }
105
+
106
+ const published = yield* fetchPublished(pkg.name);
107
+ if (published === local) {
108
+ yield* fail(
109
+ `GUARD FAIL: ${pkg.name}@${local} is already published on npm. ` +
110
+ `Bump the version in package.json to cut a new release.`,
111
+ );
112
+ }
113
+
114
+ // On the manual-dispatch path no tag is pushed, so require the version to
115
+ // exist as a git tag — keeps the tag/manifest-sync invariant on both entry
116
+ // points (clawpatch finding).
117
+ if (expected && (yield* tagExists(expected)) === false) {
118
+ yield* fail(
119
+ `GUARD FAIL: expected version "${expected}" has no matching git tag v${expected}. Tag it first.`,
120
+ );
121
+ }
122
+
123
+ return (
124
+ `GUARD OK: publishing ${pkg.name}@${local}` +
125
+ (published ? ` (latest on npm: ${published})` : " (first publish — not on npm yet)")
126
+ );
127
+ });
128
+
129
+ interface Outcome {
130
+ ok: boolean;
131
+ message: string;
132
+ }
133
+
134
+ const outcome: Outcome = await Effect.runPromise(
135
+ Effect.match(program, {
136
+ onFailure: (error: GuardError): Outcome => ({ ok: false, message: error.message }),
137
+ onSuccess: (message: string): Outcome => ({ ok: true, message }),
138
+ }),
139
+ );
140
+
141
+ if (!outcome.ok) {
142
+ console.error(outcome.message);
143
+ process.exit(1);
144
+ }
145
+ console.log(outcome.message);