pi-antiloop 1.2.0 → 1.3.0

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/README.md CHANGED
@@ -13,6 +13,7 @@
13
13
  ## Features
14
14
 
15
15
  - **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + near-identical arguments + same outcome — result-aware, so retries that make progress don't false-positive), thinking blocks, and structural opening-phrase patterns
16
+ - **Task-stream recognition (batch work)** — when another extension (e.g. `punched` appending lines to pi.md, or `plan` adding tasks) makes the model call the *same* tool many times with *different* content, antiloop recognizes it as N distinct tasks of one type and stays silent — no warning, no force break. A genuine loop (the *same* call repeated verbatim) is still caught
16
17
  - **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` injects explicit anti-loop instructions and modifies context; `abort` stops the run entirely
17
18
  - **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
18
19
  - **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
@@ -115,6 +116,9 @@ Interactive menu with current values:
115
116
  - **Tool similarity** — `0.99 / 0.95 / 0.9 / 0.8` — how close tool-call *arguments* must be to count as the same call (95% default: only near-identical repeats loop)
116
117
  - **Tool repeat** — `1 / 2 / 3` — prior occurrences of the same call set required before a tool loop flags
117
118
  - **Result similarity** — `0.95 / 0.8 / 0.6` — how similar captured results must be to count as the *same outcome* (veto when a repeated command starts succeeding/differing)
119
+ - **Task streams** — on/off — recognize homogeneous batch work (same extension tool, distinct content) as N tasks of one type, not a loop
120
+ - **Stream min calls** — `2 / 3 / 4 / 5` — same-tool calls required inside the window before batch recognition kicks in (default 3)
121
+ - **Stream twin threshold** — `99% / 95% / 90%` — arguments this similar count as the *same task*; a twin invalidates the stream and re-enables normal detection (default 99%)
118
122
  - **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
119
123
  - **Per-strategy toggles** — text / tool / thinking detection
120
124
  - **Notifications** — show detection notifications
@@ -139,6 +143,11 @@ tool different tool → no match (exp no match) ✅
139
143
  tool empty lists → no match (exp no match) ✅
140
144
  result same outcome → match (exp match — PID noise ok) ✅
141
145
  result diff outcome → no match (exp no match — error→success is progress) ✅
146
+ batch stream detected → punched_log×3 (exp punched_log×3) ✅ ← task streams: N tasks of one type
147
+ batch no detections → silent (exp silent — 98.9% args would match without gate) ✅
148
+ loop still detected → tool (exp tool — identical repeats are NOT a stream) ✅
149
+ loop survives batch gate→ tool (exp tool — bash repeats are real) ✅
150
+ stream needs ≥3 calls → no stream (exp no stream at 2 calls) ✅
142
151
  ```
143
152
 
144
153
  ## How It Works
@@ -153,6 +162,7 @@ After every assistant `message_end` event, antiloop extracts the new content (te
153
162
  | Tool | Tool name + arguments (+ captured result) | Sequence match + near-identical args (≥ `toolSimilarityThreshold`, default 95%) *and* ≥ `minToolRepeatCount` prior recurrences. **Result veto:** if both runs captured a result and the outcomes differ, it's progress, not a loop |
154
163
  | Thinking | Internal reasoning/thinking blocks | Same as text |
155
164
  | Structural | First 10 words of each message | Opening-phrase similarity ≥ 90% across ≥ 3 messages |
165
+ | Task stream | Same tool, many calls | When a tool appears ≥ `taskStreamMinCalls` times (default 3) in the window and *no two* calls are near-identical (`taskStreamTwinThreshold`, default 99%), the tool is an active batch: N different tasks of one type (e.g. `punched_log` appends, `plan_manager` task adds). Those calls are exempt from tool-loop detection, and text/thinking/structural patterns that only involve those batch messages are suppressed too. If even one call pair is a twin (the same task repeated), the tool is *not* a stream and detection proceeds normally |
156
166
 
157
167
  Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
158
168
 
@@ -206,6 +216,40 @@ If the same command produced a *different* outcome, the pair is progress:
206
216
 
207
217
  Results only veto; they never trigger on their own, and calls without a
208
218
  captured result fall back to argument matching alone.
219
+
220
+ ### Task streams: N tasks of one type ≠ a loop
221
+
222
+ Extensions push homogeneous work into the model's hands. `punched` appends
223
+ lines to pi.md one at a time; `plan` adds tasks one at a time; `obsidian_*`
224
+ writes/edits notes file by file. Each call is a *different* task — "add line 1",
225
+ "add line 2", "add line 3" — of the *same type*, and the narration around it
226
+ sounds similar ("now appending the next entry…"). That is not a reasoning
227
+ loop; flagging it would interrupt legitimate multi-step work.
228
+
229
+ Antiloop recognizes this as a **task stream**: if the same tool name appears at
230
+ least `taskStreamMinCalls` times (default 3) inside the detection window and
231
+ no two calls are "twins" (arguments ≥ `taskStreamTwinThreshold` similar,
232
+ default 99% — any real content difference counts as a distinct task), the
233
+ tool is treated as an active batch:
234
+
235
+ - tool-loop detection skips messages whose calls are all stream tools;
236
+ - text / thinking / structural detections that only involve those batch
237
+ messages are suppressed (identical narration while adding N entries is
238
+ expected, not looping);
239
+ - the footer shows it: `🔄 antiloop(on) · batch: punched_log×4`.
240
+
241
+ Crucially, the exemption requires **no twins**: the moment the same call
242
+ repeats verbatim (or near-verbatim), the stream is invalid and normal loop
243
+ detection takes over — so a model stuck re-logging the *same* line still gets
244
+ caught. The check is name-agnostic: any extension tool used as a batch is
245
+ covered, no allow-list needed. If a genuine loop coexists with a batch (e.g.
246
+ the same `bash` command re-run while appending different notes), the loop is
247
+ still flagged.
248
+
249
+ Tunables: `detectTaskStreams` (master switch), `taskStreamMinCalls` (batch
250
+ size needed before recognition), `taskStreamTwinThreshold` (how similar args
251
+ must be to count as *the same task* — lower it to treat near-duplicate
252
+ entries as loops again).
209
253
  ```
210
254
 
211
255
  ### Sliding window
@@ -226,6 +270,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
226
270
  "toolSimilarityThreshold": 0.95,
227
271
  "minToolRepeatCount": 2,
228
272
  "resultSimilarityThreshold": 0.8,
273
+ "detectTaskStreams": true,
274
+ "taskStreamMinCalls": 3,
275
+ "taskStreamTwinThreshold": 0.99,
229
276
  "detectToolLoops": true,
230
277
  "detectThinkingLoops": true,
231
278
  "detectTextLoops": true,
@@ -247,6 +294,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
247
294
  | `toolSimilarityThreshold` | `0.95` | How close tool-call arguments must be (0.0–1.0) to count as the *same* call — see [tool loops](#how-it-works) |
248
295
  | `minToolRepeatCount` | `2` | Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×) |
249
296
  | `resultSimilarityThreshold` | `0.8` | Minimum similarity between captured result tails to still count as the *same outcome*; below this, a repeated command is treated as progress, not a loop |
297
+ | `detectTaskStreams` | `true` | Recognize homogeneous batch work (same tool called with distinct content — e.g. punched/plan/obsidian extensions) and stay silent; see [task streams](#task-streams-n-tasks-of-one-type--a-loop) |
298
+ | `taskStreamMinCalls` | `3` | Same-tool calls required inside the window before a task stream is recognized |
299
+ | `taskStreamTwinThreshold` | `0.99` | Arguments this similar (or identical) count as *the same task* — a twin invalidates the stream and re-enables normal loop detection |
250
300
  | `detectTextLoops` | `true` | Detect full-text repetition |
251
301
  | `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
252
302
  | `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.2.0",
4
- "description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns. Tool-loop detection is result-aware: only near-identical repeated calls with the same outcome count, so sequential bash operations and retries that make progress don't false-positive.",
3
+ "version": "1.3.0",
4
+ "description": "Antiloop: detect reasoning loops and force a break (warn → force → abort) across text, tool, thinking, and structural patterns. Tool-loop detection is result-aware: only near-identical repeated calls with the same outcome count, so sequential bash operations and retries that make progress don't false-positive. Task-stream recognition: when an extension (punched, plan, …) makes the model call the SAME tool many times with DIFFERENT content — N distinct tasks of one type, e.g. appending lines or adding plan tasks — antiloop stays silent.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "antiloop",
package/src/commands.ts CHANGED
@@ -59,10 +59,14 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
59
59
  ` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
60
60
  ` tool sim: ${(rt.config.toolSimilarityThreshold * 100).toFixed(0)}% tool repeat: ${rt.config.minToolRepeatCount}+ prior`,
61
61
  ` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
62
+ ` task streams: ${yn(rt.config.detectTaskStreams)} (min ${rt.config.taskStreamMinCalls} calls, twins ≥ ${(rt.config.taskStreamTwinThreshold * 100).toFixed(0)}%)`,
62
63
  "",
63
64
  `detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
64
65
  `footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
65
66
  ];
67
+ if (rt.state.activeTaskStreams.length) {
68
+ lines.push("", `active batch: ${rt.state.activeTaskStreams.map((x) => `${x.tool}×${x.count}`).join(", ")}`);
69
+ }
66
70
  if (recent.length) {
67
71
  lines.push("", "recent:");
68
72
  for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
@@ -81,6 +85,9 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
81
85
  { value: "toolSim" as const, label: `tool similarity: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "args must match this closely to count as the same call" },
82
86
  { value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
83
87
  { value: "resultSim" as const, label: `result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same cmd + different outcome vetoes the loop" },
88
+ { value: "streams" as const, label: `task streams: ${yn(c.detectTaskStreams)}`, description: "batch work via extensions (punched_log / plan_manager / …) is not a loop" },
89
+ { value: "streamMin" as const, label: `stream min calls: ${c.taskStreamMinCalls}`, description: "same tool calls required before batch recognition kicks in" },
90
+ { value: "streamTwin" as const, label: `stream twin threshold: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "args this similar = same task repeated → not a stream" },
84
91
  { value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
85
92
  { value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
86
93
  { value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
@@ -169,6 +176,28 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
169
176
  if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
170
177
  break;
171
178
  }
179
+ case "streams":
180
+ c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
181
+ ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
182
+ case "streamMin": {
183
+ const v = await selectFrom(ctx, "stream min calls", [
184
+ { value: 2, label: "2 (sensitive)" },
185
+ { value: 3, label: "3 (default)" },
186
+ { value: 4, label: "4" },
187
+ { value: 5, label: "5 (conservative)" },
188
+ ]);
189
+ if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`stream min calls: ${v}`, "info"); }
190
+ break;
191
+ }
192
+ case "streamTwin": {
193
+ const v = await selectFrom(ctx, "stream twin threshold (args)", [
194
+ { value: 0.99, label: "99% (default — any real difference = distinct task)" },
195
+ { value: 0.95, label: "95% (near-identical args count as same task)" },
196
+ { value: 0.9, label: "90% (aggressive loop detection)" },
197
+ ]);
198
+ if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`stream twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
199
+ break;
200
+ }
172
201
  case "window": {
173
202
  const v = await selectFrom(ctx, "window", [
174
203
  { value: 5, label: "5" },
@@ -230,6 +259,7 @@ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void>
230
259
  export function resetState(state: AntiloopState): void {
231
260
  state.recentMessages = [];
232
261
  state.detections = [];
262
+ state.activeTaskStreams = [];
233
263
  state.currentLevel = 0;
234
264
  state.consecutiveDetections = 0;
235
265
  state.inForcedBreak = false;
package/src/config.ts CHANGED
@@ -16,6 +16,9 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
16
16
  toolSimilarityThreshold: 0.95,
17
17
  minToolRepeatCount: 2,
18
18
  resultSimilarityThreshold: 0.8,
19
+ detectTaskStreams: true,
20
+ taskStreamMinCalls: 3,
21
+ taskStreamTwinThreshold: 0.99,
19
22
  detectToolLoops: true,
20
23
  detectThinkingLoops: true,
21
24
  detectTextLoops: true,
package/src/detect.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // antiloop — similarity + detection engine. Lazy-loaded on first message_end.
2
2
 
3
- import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedToolCall } from "./types.ts";
3
+ import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedMessage, TrackedToolCall } from "./types.ts";
4
4
 
5
5
  const MIN_CONTENT_LENGTH = 50;
6
6
 
@@ -108,6 +108,71 @@ function toolCallsSimilar(
108
108
  return true;
109
109
  }
110
110
 
111
+ // ---------------------------------------------------------------------------
112
+ // Task-stream recognition (batch work).
113
+ //
114
+ // Extensions like `punched` (append lines to pi.md) or `plan` (add tasks) make
115
+ // the model call the SAME tool many times in a row with DIFFERENT content:
116
+ // N distinct tasks of the same type — "adding line 1…N to a doc", "adding task
117
+ // 1…N to the plan". That is not a reasoning loop, and antiloop must not fire.
118
+ //
119
+ // A tool is an active task stream when, inside the detection window, it was
120
+ // called at least taskStreamMinCalls times and NO two calls are "twins"
121
+ // (args ≥ taskStreamTwinThreshold similar). Twins = the same task repeated;
122
+ // a stream with twins is indistinguishable from a loop and stays detected.
123
+ // The check is name-agnostic: any extension tool used as a batch is covered.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /** Near-identity of two argument payloads — unlike similarity() it works on
127
+ * short args (JSON templates like {"action":"add",...} are < 50 chars, where
128
+ * similarity() bails to 0).
129
+ */
130
+ function argsTwin(a: string, b: string, threshold: number): boolean {
131
+ const na = normalizeText(a);
132
+ const nb = normalizeText(b);
133
+ if (!na.length || !nb.length) return na === nb;
134
+ if (na === nb) return true;
135
+ if (na.length < 100 && nb.length < 100) {
136
+ const max = Math.max(na.length, nb.length);
137
+ return max > 0 && 1 - levenshtein(na, nb) / max >= threshold;
138
+ }
139
+ return similarity(a, b) >= threshold;
140
+ }
141
+
142
+ /**
143
+ * Map of tool name → call count for tools currently used as a homogeneous
144
+ * task stream in the given window. Empty map = no batch work recognized.
145
+ */
146
+ export function detectTaskStreams(
147
+ win: TrackedMessage[],
148
+ config: AntiloopConfig,
149
+ ): Map<string, number> {
150
+ const streams = new Map<string, number>();
151
+ if (!config.detectTaskStreams || win.length < config.taskStreamMinCalls) return streams;
152
+
153
+ const callsByName = new Map<string, string[]>();
154
+ for (const m of win) {
155
+ for (const tc of m.toolCalls ?? []) {
156
+ const arr = callsByName.get(tc.name) ?? [];
157
+ arr.push(tc.args);
158
+ callsByName.set(tc.name, arr);
159
+ }
160
+ }
161
+
162
+ for (const [name, argsList] of callsByName) {
163
+ if (argsList.length < config.taskStreamMinCalls) continue;
164
+ let twins = 0;
165
+ for (let i = 0; i < argsList.length; i++) {
166
+ for (let j = i + 1; j < argsList.length; j++) {
167
+ if (argsTwin(argsList[i], argsList[j], config.taskStreamTwinThreshold)) twins++;
168
+ }
169
+ }
170
+ // Every call is a distinct task → batch work, exempt from loop detection.
171
+ if (twins === 0) streams.set(name, argsList.length);
172
+ }
173
+ return streams;
174
+ }
175
+
111
176
  export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
112
177
  const out: LoopDetection[] = [];
113
178
  const msgs = state.recentMessages;
@@ -116,13 +181,28 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
116
181
  const win = msgs.slice(start);
117
182
  const now = Date.now();
118
183
 
184
+ // Task-stream gate: if the window is a homogeneous batch (same extension
185
+ // tool called with DISTINCT content ≥ taskStreamMinCalls times), that tool
186
+ // is exempt from tool-loop detection, and text/thinking/structural
187
+ // detections that only involve batch messages are suppressed too — the
188
+ // model is doing N different tasks of the same type, not looping.
189
+ const streams = detectTaskStreams(win, config);
190
+ const batchAt = new Set<number>();
191
+ win.forEach((m, idx) => {
192
+ const calls = m.toolCalls;
193
+ if (calls && calls.length && calls.every((c) => (streams.get(c.name) ?? 0) >= config.taskStreamMinCalls)) {
194
+ batchAt.add(start + idx);
195
+ }
196
+ });
197
+ const allBatch = (idxs: number[]): boolean => idxs.every((i) => batchAt.has(i));
198
+
119
199
  if (config.detectTextLoops) {
120
200
  const last = win[win.length - 1];
121
201
  if (last.content.length >= MIN_CONTENT_LENGTH) {
122
202
  for (let i = 0; i < win.length - 1; i++) {
123
203
  if (win[i].content.length < MIN_CONTENT_LENGTH) continue;
124
204
  const s = similarity(last.content, win[i].content);
125
- if (s >= config.similarityThreshold) {
205
+ if (s >= config.similarityThreshold && !allBatch([start + i, msgs.length - 1])) {
126
206
  out.push({
127
207
  type: "text",
128
208
  similarity: s,
@@ -142,7 +222,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
142
222
  for (let i = 0; i < opens.length - 1; i++) {
143
223
  if (similarity(last, opens[i].o) > 0.9) n++;
144
224
  }
145
- if (n >= 2) {
225
+ if (n >= 2 && !allBatch([msgs.length - 1])) {
146
226
  out.push({
147
227
  type: "structural",
148
228
  similarity: 0.9,
@@ -158,7 +238,9 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
158
238
  if (config.detectToolLoops) {
159
239
  const last = win[win.length - 1];
160
240
  const lastCalls = last.toolCalls;
161
- if (lastCalls && lastCalls.length) {
241
+ // A message whose calls are all task-stream tools is batch work — skip
242
+ // it entirely (the stream gate already proved the calls are distinct).
243
+ if (lastCalls && lastCalls.length && !batchAt.has(msgs.length - 1)) {
162
244
  const matched: number[] = [];
163
245
  for (let i = 0; i < win.length - 1; i++) {
164
246
  const prev = win[i].toolCalls;
@@ -187,7 +269,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
187
269
  for (let i = 0; i < win.length - 1; i++) {
188
270
  if (win[i].thinking && win[i].thinking!.length > 50) {
189
271
  const s = similarity(last.thinking, win[i].thinking!);
190
- if (s >= config.similarityThreshold) {
272
+ if (s >= config.similarityThreshold && !allBatch([start + i, msgs.length - 1])) {
191
273
  out.push({
192
274
  type: "thinking",
193
275
  similarity: s,
@@ -276,5 +358,67 @@ export function runSelfTest(): string[] {
276
358
  out.push(`result same outcome → ${sameCmdSameOut ? "match" : "no match"} (exp match — PID noise ok) ${sameCmdSameOut ? "✅" : "❌"}`);
277
359
  out.push(`result diff outcome → ${sameCmdDiffOut ? "match" : "no match"} (exp no match — error→success is progress) ${!sameCmdDiffOut ? "✅" : "❌"}`);
278
360
 
361
+ // --- task streams: extension batch work is NOT a loop ---
362
+ // punched_log / plan_manager / obsidian_* style tools: the model calls the
363
+ // SAME tool N times with DIFFERENT content ("add line 1…N", "add task 1…N").
364
+ // Near-identical template args (98.9% similar here) WOULD match the tool
365
+ // detector; the task-stream gate must suppress the whole window instead.
366
+ const mk = (content: string, toolCalls?: TrackedToolCall[]): TrackedMessage =>
367
+ ({ content, toolCalls, timestamp: Date.now(), turnIndex: 0 });
368
+ const noteArgs = (x: string) =>
369
+ JSON.stringify({ type: "note", title: `task ${x}`, body: "append this line to the project memory document so context is preserved" });
370
+ const tcfg: AntiloopConfig = {
371
+ enabled: true, warningThreshold: 2, forceBreakThreshold: 3, abortThreshold: 0,
372
+ similarityThreshold: 0.75, toolSimilarityThreshold: 0.95, minToolRepeatCount: 2,
373
+ resultSimilarityThreshold: 0.8, detectToolLoops: true, detectThinkingLoops: true,
374
+ detectTextLoops: true, notifyOnDetection: true, maxHistoryEntries: 100,
375
+ detectionWindow: 10, interactiveFooter: true, toggleShortcut: "esc+a",
376
+ detectTaskStreams: true, taskStreamMinCalls: 3, taskStreamTwinThreshold: 0.99,
377
+ };
378
+ const asState = (recentMessages: TrackedMessage[]): AntiloopState =>
379
+ ({ recentMessages, detections: [], activeTaskStreams: [], currentLevel: 0,
380
+ consecutiveDetections: 0, inForcedBreak: false, totalDetections: 0,
381
+ lastUserMessageTime: 0, lastDetectedTurnIndex: -1 });
382
+ const NARR = "Now I will append the next decision entry to the project memory document so we keep the context.";
383
+
384
+ // 1) punched_log batch: 3 DIFFERENT appends (args 98.9% similar, NOT twins)
385
+ // → stream recognized, tool + text + structural all suppressed.
386
+ const batchMsgs = [
387
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
388
+ mk(NARR, [{ name: "punched_log", args: noteArgs("2") }]),
389
+ mk(NARR, [{ name: "punched_log", args: noteArgs("3") }]),
390
+ ];
391
+ const batchWin = batchMsgs.slice(-tcfg.detectionWindow);
392
+ const batchStreams = detectTaskStreams(batchWin, tcfg);
393
+ const batchDet = detectLoops(asState(batchMsgs), tcfg);
394
+ out.push(`batch stream detected → ${batchStreams.get("punched_log") === 3 ? `punched_log×${batchStreams.get("punched_log")}` : "no"} (exp punched_log×3) ${batchStreams.get("punched_log") === 3 ? "✅" : "❌"}`);
395
+ out.push(`batch no detections → ${batchDet.length === 0 ? "silent" : `${batchDet.map((d) => d.type).join(",")}`} (exp silent — 98.9% args would match without gate) ${batchDet.length === 0 ? "✅" : "❌"}`);
396
+
397
+ // 2) genuine loop: SAME call repeated verbatim → twins → stream inactive,
398
+ // tool detection must still fire.
399
+ const loopMsgs = [
400
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
401
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
402
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
403
+ ];
404
+ const loopDet = detectLoops(asState(loopMsgs), tcfg);
405
+ out.push(`loop still detected → ${loopDet.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — identical repeats are NOT a stream) ${loopDet.some((d) => d.type === "tool") ? "✅" : "❌"}`);
406
+
407
+ // 3) coexistence: batch tool + a genuinely repeated bash command in the
408
+ // same messages → the bash loop must still be flagged.
409
+ const loopCmd = "cd /tmp && sleep 1 && echo retrying the same build step again and again forever";
410
+ const mixedMsgs = [
411
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }, { name: "bash", args: loopCmd }]),
412
+ mk(NARR, [{ name: "punched_log", args: noteArgs("2") }, { name: "bash", args: loopCmd }]),
413
+ mk(NARR, [{ name: "punched_log", args: noteArgs("3") }, { name: "bash", args: loopCmd }]),
414
+ ];
415
+ const mixedDet = detectLoops(asState(mixedMsgs), tcfg);
416
+ out.push(`loop survives batch gate→ ${mixedDet.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — bash repeats are real) ${mixedDet.some((d) => d.type === "tool") ? "✅" : "❌"}`);
417
+
418
+ // 4) below min calls: 2 distinct appends → no stream (could be coincidence).
419
+ const twoMsgs = [mk("a", [{ name: "punched_log", args: noteArgs("1") }]), mk("b", [{ name: "punched_log", args: noteArgs("2") }])];
420
+ const twoStreams = detectTaskStreams(twoMsgs, tcfg);
421
+ out.push(`stream needs ≥3 calls → ${twoStreams.size === 0 ? "no stream" : "stream"} (exp no stream at 2 calls) ${twoStreams.size === 0 ? "✅" : "❌"}`);
422
+
279
423
  return out;
280
424
  }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ function newState(): AntiloopState {
16
16
  return {
17
17
  recentMessages: [],
18
18
  detections: [],
19
+ activeTaskStreams: [],
19
20
  currentLevel: 0,
20
21
  consecutiveDetections: 0,
21
22
  inForcedBreak: false,
@@ -42,11 +43,19 @@ export default function antiloopExtension(pi: ExtensionAPI) {
42
43
  /** TUI handle for forcing footer re-renders (set by the footer factory). */
43
44
  let activeTui: { requestRender(force?: boolean): void } | undefined;
44
45
 
45
- /** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec. */
46
+ /** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec,
47
+ * plus active batch streams so a quiet antiloop is explainable. */
46
48
  function antiloopStatusText(): string {
47
49
  if (!config.enabled) return "🔄 antiloop(off)";
48
- if (state.currentLevel === 0) return "🔄 antiloop(on)";
49
- return `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
50
+ const base =
51
+ state.currentLevel === 0
52
+ ? "🔄 antiloop(on)"
53
+ : `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
54
+ if (state.activeTaskStreams.length) {
55
+ const s = state.activeTaskStreams.map((x) => `${x.tool}×${x.count}`).join(", ");
56
+ return `${base} · batch: ${s}`;
57
+ }
58
+ return base;
50
59
  }
51
60
 
52
61
  function updateStatus(ctx: ExtensionContext): void {
@@ -266,7 +275,7 @@ export default function antiloopExtension(pi: ExtensionAPI) {
266
275
 
267
276
  pi.on("turn_end", async (event, ctx) => {
268
277
  if (!config.enabled) return;
269
- const { detectLoops, interventionMessage, resultFingerprint } = await import("./detect.ts");
278
+ const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
270
279
 
271
280
  const last = state.recentMessages[state.recentMessages.length - 1];
272
281
  // A turn whose assistant message wasn't tracked (short text, no tools)
@@ -292,6 +301,14 @@ export default function antiloopExtension(pi: ExtensionAPI) {
292
301
  }
293
302
  }
294
303
 
304
+ // Recomputed batch streams (punched_log / plan_manager / … used as a
305
+ // homogeneous task stream) — shown in the footer so the user sees why
306
+ // antiloop stays quiet while the model performs N tasks of one type.
307
+ const winStart = Math.max(0, state.recentMessages.length - config.detectionWindow);
308
+ state.activeTaskStreams = Array.from(
309
+ detectTaskStreams(state.recentMessages.slice(winStart), config).entries(),
310
+ ).map(([tool, count]) => ({ tool, count }));
311
+
295
312
  const detections = detectLoops(state, config);
296
313
  processDetections(detections, interventionMessage);
297
314
 
package/src/types.ts CHANGED
@@ -25,6 +25,19 @@ export interface AntiloopConfig {
25
25
  * loop. Same command + different outcome = progress, not a loop.
26
26
  */
27
27
  resultSimilarityThreshold: number;
28
+ /**
29
+ * Task-stream recognition (batch work). Extensions like punched (append
30
+ * lines to pi.md) or plan (add tasks) make the model call the SAME tool
31
+ * many times with DIFFERENT content — N distinct tasks of the same type,
32
+ * not a loop. When the same tool appears at least taskStreamMinCalls times
33
+ * in the window and no two calls are "twins" (args ≥ taskStreamTwinThreshold
34
+ * similar), antiloop treats that tool as an active task stream and does not
35
+ * flag repetitions of it, nor text/thinking/structural patterns that only
36
+ * involve those batch messages.
37
+ */
38
+ detectTaskStreams: boolean;
39
+ taskStreamMinCalls: number;
40
+ taskStreamTwinThreshold: number;
28
41
  detectToolLoops: boolean;
29
42
  detectThinkingLoops: boolean;
30
43
  detectTextLoops: boolean;
@@ -75,9 +88,18 @@ export interface TrackedMessage {
75
88
  turnIndex: number;
76
89
  }
77
90
 
91
+ export interface TaskStreamInfo {
92
+ tool: string;
93
+ count: number;
94
+ }
95
+
78
96
  export interface AntiloopState {
79
97
  recentMessages: TrackedMessage[];
80
98
  detections: LoopDetection[];
99
+ /** Tools currently being used as a homogeneous batch (N tasks, same type).
100
+ * Recomputed at turn_end; shown in the footer/status so a quiet antiloop is
101
+ * explainable. */
102
+ activeTaskStreams: TaskStreamInfo[];
81
103
  currentLevel: 0 | 1 | 2 | 3;
82
104
  consecutiveDetections: number;
83
105
  inForcedBreak: boolean;