pi-anti-doom-loop 0.0.6 → 0.0.7

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/CHANGELOG.md CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  All notable changes to **pi-anti-doom-loop**.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.0.7] — 2026-08-13
6
+
7
+ ### Changed
8
+
9
+ - **Effect 4.0 RC** — upgraded `effect` from `^4.0.0-beta.103` to `^4.0.0-rc.108`.
10
+ - **Anti-slop lint hardening** — vendored an opinionated Oxlint rule set (`tools/oxlint/anti-slop/`) and enabled it in `oxlint.config.ts`; resolved all 23 findings by introducing a JSON-safe `ToolInput` domain type decoded at the pi boundary, replacing `unknown` parameters, `typeof` checks, `Record<string, unknown>`, and value widening.
11
+
12
+ ## [0.0.6] — 2026-08-12
6
13
 
7
14
  ### Added
8
15
 
@@ -97,7 +104,10 @@ All notable changes to **pi-anti-doom-loop**.
97
104
  - GitHub Actions release workflow: quality gate → version bump guard → dry-run → publish, triggered by `v*` tags.
98
105
  - `pi-package` keyword + `pi` manifest for the pi.dev gallery.
99
106
 
107
+ [0.0.7]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.6...v0.0.7
108
+ [0.0.6]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.5...v0.0.6
100
109
  [0.0.5]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.4...v0.0.5
101
110
  [0.0.4]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.3...v0.0.4
111
+ [0.0.3]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.2...v0.0.3
102
112
  [0.0.2]: https://github.com/irfndi/pi-anti-doom-loop/compare/v0.0.1...v0.0.2
103
113
  [0.0.1]: https://github.com/irfndi/pi-anti-doom-loop/releases/tag/v0.0.1
@@ -13,7 +13,7 @@
13
13
  * a fresh session (new controller) starts over.
14
14
  */
15
15
  import { LoopDetector, readOptions } from "./detector.ts";
16
- import type { LoopOptions } from "./detector.ts";
16
+ import type { LoopOptions, ToolInput } from "./detector.ts";
17
17
 
18
18
  /** Minimal shapes of the pi events the controller consumes (structural). */
19
19
  export interface ToolCallEventLite {
@@ -37,6 +37,15 @@ export interface CommandCtxLite {
37
37
  ui: { notify(message: string, level: string): void };
38
38
  }
39
39
 
40
+ /** One content block of an assistant message; only text blocks carry text. */
41
+ export interface MessageContentBlock {
42
+ readonly type: string;
43
+ readonly text?: string;
44
+ }
45
+
46
+ /** The list of content blocks of an assistant message. */
47
+ export type MessageContent = readonly MessageContentBlock[];
48
+
40
49
  export interface ToolCallOutcome {
41
50
  block: true;
42
51
  reason: string;
@@ -56,11 +65,11 @@ export const RESUME_BUDGET = 1;
56
65
 
57
66
  export interface AntiLoopController {
58
67
  /** Returns a block decision for a tool call, or null to let it run. */
59
- onToolCall(toolName: string, input: unknown, toolCallId: string): ToolCallOutcome | null;
68
+ onToolCall(toolName: string, input: ToolInput, toolCallId: string): ToolCallOutcome | null;
60
69
  /** Record a finished tool result (blocked calls' results are ignored). */
61
70
  onToolResult(toolName: string, toolCallId: string, isError: boolean): void;
62
71
  /** Detect assistant-text loops; returns a steer/abort decision or null. */
63
- onMessageEnd(role: string, content: unknown): TextLoopOutcome | null;
72
+ onMessageEnd(role: string, content: MessageContent): TextLoopOutcome | null;
64
73
  /** Full reset (session start, user prompt, /loopcheck reset). */
65
74
  reset(): void;
66
75
  /** Suspend detection until the next reset (escape hatch for intentional repetition). */
@@ -164,13 +173,6 @@ export function createController(opts: LoopOptions = readOptions()): AntiLoopCon
164
173
  }
165
174
 
166
175
  /** Join the text content blocks of an assistant message. */
167
- export function extractText(content: unknown): string {
168
- if (!Array.isArray(content)) return "";
169
- return content
170
- .map((c) =>
171
- typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
172
- ? c.text
173
- : "",
174
- )
175
- .join(" ");
176
+ export function extractText(content: MessageContent): string {
177
+ return content.map((c) => (c.type === "text" ? (c.text ?? "") : "")).join(" ");
176
178
  }
@@ -77,18 +77,32 @@ export function readOptions(env: Record<string, string | undefined> = process.en
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * A JSON-safe tool-call argument value. Tool inputs arrive from the pi event
82
+ * loop as untyped data; they are decoded into this domain type at the I/O
83
+ * boundary (see index.ts) before the detector fingerprints them.
84
+ */
85
+ export type ToolInput =
86
+ | null
87
+ | boolean
88
+ | number
89
+ | string
90
+ | ToolInput[]
91
+ | { readonly [key: string]: ToolInput };
92
+
80
93
  /** Keys sorted recursively so {a:1,b:2} and {b:2,a:1} share a signature. */
81
- export function canonical(input: unknown): string {
82
- if (input === null || typeof input !== "object") return JSON.stringify(input);
94
+ export function canonical(input: ToolInput): string {
83
95
  if (Array.isArray(input)) return `[${input.map(canonical).join(",")}]`;
84
- const obj = input as Record<string, unknown>;
85
- return `{${Object.keys(obj)
86
- .sort()
87
- .map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`)
88
- .join(",")}}`;
96
+ if (input instanceof Object) {
97
+ return `{${Object.keys(input)
98
+ .sort()
99
+ .map((k) => `${JSON.stringify(k)}:${canonical(input[k])}`)
100
+ .join(",")}}`;
101
+ }
102
+ return JSON.stringify(input) ?? "";
89
103
  }
90
104
 
91
- export function signature(toolName: string, input: unknown): string {
105
+ export function signature(toolName: string, input: ToolInput): string {
92
106
  return `${toolName}:${canonical(input)}`;
93
107
  }
94
108
 
@@ -118,7 +132,7 @@ export class LoopDetector {
118
132
  this.opts = opts;
119
133
  }
120
134
 
121
- check(toolName: string, input: unknown): Result<BlockDecision, undefined> {
135
+ check(toolName: string, input: ToolInput): Result<BlockDecision, undefined> {
122
136
  if (this.opts.toolExclude.has(toolName)) {
123
137
  // record() is a no-op for excluded tools, so nothing enters the window.
124
138
  return Result.err(undefined);
@@ -168,7 +182,7 @@ export class LoopDetector {
168
182
  });
169
183
  }
170
184
 
171
- record(toolName: string, input: unknown): void {
185
+ record(toolName: string, input: ToolInput): void {
172
186
  if (this.opts.toolExclude.has(toolName)) return;
173
187
  this.recentSigs.push({ sig: signature(toolName, input), ts: Date.now() });
174
188
  this.evictSigs();
@@ -272,7 +286,7 @@ export class LoopDetector {
272
286
  }
273
287
 
274
288
  /** Error share of all in-window results for a tool. */
275
- private failRate(toolName: string): { calls: number; errors: number; rate: number } {
289
+ private failRate(toolName: string) {
276
290
  let calls = 0;
277
291
  let errors = 0;
278
292
  for (const r of this.recentResults) {
@@ -370,14 +384,9 @@ export const MIN_REPEAT_CHUNK = 16;
370
384
  /** Jaccard similarity threshold for "near-identical" consecutive texts. */
371
385
  export const TEXT_SIMILARITY_THRESHOLD = 0.55;
372
386
 
373
- /** Stable string form of an arbitrary tool input (used for token estimation). */
374
- export function stringify(input: unknown): string {
375
- if (typeof input === "string") return input;
376
- try {
377
- return JSON.stringify(input);
378
- } catch {
379
- return String(input);
380
- }
387
+ /** Stable string form of a tool input (used for token estimation). */
388
+ export function stringify(input: ToolInput): string {
389
+ return JSON.stringify(input) ?? "";
381
390
  }
382
391
 
383
392
  /**
@@ -31,11 +31,12 @@ import {
31
31
  type AntiLoopController,
32
32
  type CommandCtxLite,
33
33
  type CtxLite,
34
+ type MessageContent,
34
35
  type MessageEndEventLite,
35
36
  type ToolCallEventLite,
36
37
  type ToolResultEventLite,
37
38
  } from "./controller.ts";
38
- import { readOptions } from "./detector.ts";
39
+ import { readOptions, type ToolInput } from "./detector.ts";
39
40
 
40
41
  /** The subset of pi's ExtensionAPI this extension uses (structural). */
41
42
  export interface PiLike {
@@ -77,7 +78,13 @@ export default function (pi: PiLike): void {
77
78
  pi.on("before_agent_start", () => controller.reset());
78
79
 
79
80
  pi.on("tool_call", (event: ToolCallEventLite, ctx: CtxLite) => {
80
- const outcome = controller.onToolCall(event.toolName, event.input, event.toolCallId);
81
+ // The pi event delivers untyped tool arguments; decode them into the
82
+ // ToolInput domain type at this I/O boundary before the controller sees them.
83
+ const outcome = controller.onToolCall(
84
+ event.toolName,
85
+ event.input as ToolInput,
86
+ event.toolCallId,
87
+ );
81
88
  if (outcome === null) return;
82
89
  if (outcome.escalate) {
83
90
  ctx.ui.notify("Anti-doom-loop: identical call blocked again — aborting turn", "error");
@@ -94,7 +101,11 @@ export default function (pi: PiLike): void {
94
101
  // tool calls) never reach tool_call. Steer first, abort as escalation,
95
102
  // then a bounded auto-resume so the work continues.
96
103
  pi.on("message_end", (event: MessageEndEventLite, ctx: CtxLite) => {
97
- const outcome = controller.onMessageEnd(event.message.role, event.message.content);
104
+ // Decode the untyped message content into MessageContent at this boundary.
105
+ const outcome = controller.onMessageEnd(
106
+ event.message.role,
107
+ event.message.content as MessageContent,
108
+ );
98
109
  if (outcome === null) return;
99
110
 
100
111
  if (outcome.action === "steer") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anti-doom-loop",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Detect and break agent doom loops in pi: blocks identical repeated tool calls and blind retries before they burn tokens.",
5
5
  "keywords": [
6
6
  "anti-doom-loop",
@@ -41,11 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "better-result": "^3.0.0",
44
- "effect": "^4.0.0-beta.103"
44
+ "effect": "^4.0.0-rc.108"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@earendil-works/pi-coding-agent": "*",
48
48
  "@effect/tsgo": "^0.36.4",
49
+ "@oxlint/plugins": "^1.77.0",
49
50
  "@types/node": "^22.0.0",
50
51
  "oxfmt": "^0.62.0",
51
52
  "oxlint": "^1.77.0",