pi-antiloop 1.3.1 → 1.4.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/README.md CHANGED
@@ -106,23 +106,35 @@ Recent detections:
106
106
 
107
107
  ### `/antiloop config`
108
108
 
109
- Interactive menu with current values:
110
-
111
- - **Enable/disable**
112
- - **Warning threshold** — similar messages before warning (default 2)
113
- - **Force break threshold**similar messages before force break (default 3)
114
- - **Abort threshold** — similar messages before abort (0 = disabled)
115
- - **Similarity threshold** — `0.5 / 0.6 / 0.7 / 0.75 / 0.8 / 0.9` how close two messages must be to count as looping
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)
117
- - **Tool repeat** — `1 / 2 / 3` — prior occurrences of the same call set required before a tool loop flags
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%)
122
- - **Detection window** — `5 / 10 / 15 / 20`number of recent messages to analyze
123
- - **Per-strategy toggles** — text / tool / thinking detection
124
- - **Notifications**show detection notifications
125
- - **Reset state** — clear all counters and history
109
+ Grouped interactive menu showing the current value in each option:
110
+
111
+ **🎛️ General**
112
+ - **🟢/🔴 enable/disable** — turn detection on or off
113
+ - **⏳ window** `5 / 10 / 15 / 20` how many recent messages are analyzed (default 10)
114
+ - **🔔 notifications** — on/off show a warning when a loop is detected
115
+ - **📊 interactive footer** — on/off experimental: replaces pi's footer and captures keystrokes for the toggle shortcut (leave it off if it misbehaves)
116
+ - **⌨️ shortcut** — `esc+a` or offkey press to toggle without typing a command
117
+
118
+ **🎯 Detection**
119
+ - **⚠️ warn threshold** — `1 / 2 / 3 / 5` repetitions before antiloop warns you (default 2)
120
+ - **🛑 force break threshold** — `2 / 3 / 5 / 8` — repetitions before forcing a change of approach (default 3)
121
+ - **🚨 abort threshold** — `off / 5 / 8 / 10 / 15` repetitions before aborting (0 = disabled)
122
+ - **📏 text similarity** — `50 / 60 / 70 / 75 / 80 / 90%` how similar two messages must be to count as a loop (default 75%)
123
+ - **🔧 call similarity** — `99 / 95 / 90 / 80%` — how identical tool-call *arguments* must be to count as the same call (default 95%: only near-identical repeats loop)
124
+ - **🔁 call repeats** — `1 / 2 / 3` — how many times the same call must repeat before it flags (default 2)
125
+ - **🧾 result similarity** — `95 / 80 / 60%` — how similar captured results must be to count as the *same outcome*; a repeated command that starts producing a different result is progress, not a loop (default 80%)
126
+
127
+ **📋 Task streams** — batch work (punched_log, plan_manager, …) is N tasks of one type, not a loop
128
+ - **📋 task streams** — on/off — recognize that batch work and stay silent
129
+ - **📋 stream min calls** — `2 / 3 / 4 / 5` — same-tool calls required before a batch is recognized (default 3)
130
+ - **📋 twin threshold** — `99 / 95 / 90%` — calls more similar than this count as the *same task* repeated; one twin invalidates the batch and normal detection resumes (default 99%)
131
+
132
+ **🔍 Detectors**
133
+ - **📝 text** — on/off — detect repeated text messages
134
+ - **🔧 tools** — on/off — detect repeated tool calls
135
+ - **🧠 thinking** — on/off — detect repeated internal reasoning
136
+
137
+ **🧹 reset state** — clear all counters and history
126
138
 
127
139
  ### `/antiloop log`
128
140
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "description": "Antiloop: detect reasoning loops and force a break (warn \u2192 force \u2192 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, \u2026) makes the model call the SAME tool many times with DIFFERENT content \u2014 N distinct tasks of one type, e.g. appending lines or adding plan tasks \u2014 antiloop stays silent.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/commands.ts CHANGED
@@ -76,26 +76,31 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
76
76
 
77
77
  async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
78
78
  const c = rt.config;
79
- const picked = await selectFrom(ctx, "antiloop config", [
80
- { value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "toggle detection" },
81
- { value: "warn" as const, label: `warn threshold: ${c.warningThreshold}` },
82
- { value: "force" as const, label: `force threshold: ${c.forceBreakThreshold}` },
83
- { value: "abort" as const, label: `abort threshold: ${c.abortThreshold || "off"}`, description: "0 = disabled" },
84
- { value: "sim" as const, label: `similarity: ${(c.similarityThreshold * 100).toFixed(0)}%` },
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" },
86
- { value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
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" },
91
- { value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
92
- { value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
93
- { value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
94
- { value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
95
- { value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
96
- { value: "footer" as const, label: `interactive footer: ${yn(c.interactiveFooter)}`, description: "TUI footer with toggle shortcut" },
97
- { value: "shortcut" as const, label: `toggle shortcut: ${c.toggleShortcut}`, description: "esc+a or off" },
98
- { value: "reset" as const, label: "reset state" },
79
+ const picked = await selectFrom(ctx, "⚙️ antiloop config", [
80
+ // ── 🎛️ General ──────────────────────────────────────────────
81
+ { value: "toggle" as const, label: c.enabled ? "🟢 disable" : "🔴 enable", description: "turn detection on or off" },
82
+ { value: "window" as const, label: `⏳ window: ${c.detectionWindow} msgs`, description: "how many recent messages are analyzed" },
83
+ { value: "notify" as const, label: `🔔 notifications: ${yn(c.notifyOnDetection)}`, description: "show a warning when a loop is detected" },
84
+ { value: "footer" as const, label: `📊 interactive footer: ${yn(c.interactiveFooter)}`, description: "experimental: replaces pi's footer and captures keystrokes (leave off if it misbehaves)" },
85
+ { value: "shortcut" as const, label: `⌨️ shortcut: ${c.toggleShortcut}`, description: "keys to toggle on/off without typing a command" },
86
+ // ── 🎯 Detection ────────────────────────────────────────────
87
+ { value: "warn" as const, label: `⚠️ warn after: ${c.warningThreshold}`, description: "repetitions before antiloop warns you" },
88
+ { value: "force" as const, label: `🛑 force break after: ${c.forceBreakThreshold}`, description: "repetitions before forcing a change of approach" },
89
+ { value: "abort" as const, label: `🚨 abort after: ${c.abortThreshold || "off"}`, description: "repetitions before aborting (0 = disabled)" },
90
+ { value: "sim" as const, label: `📏 text similarity: ${(c.similarityThreshold * 100).toFixed(0)}%`, description: "how similar two messages must be to count as a loop" },
91
+ { value: "toolSim" as const, label: `🔧 call similarity: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "how identical tool calls must be to count as the same call" },
92
+ { value: "toolRepeat" as const, label: `🔁 call repeats: ${c.minToolRepeatCount}+`, description: "how many times the same call must repeat before it flags" },
93
+ { value: "resultSim" as const, label: `🧾 result similarity: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "same command + different result = progress, not a loop" },
94
+ // ── 📋 Task streams ─────────────────────────────────────────
95
+ { value: "streams" as const, label: `📋 task streams: ${yn(c.detectTaskStreams)}`, description: "batch work (punched_log / plan_manager / …) is not a loop" },
96
+ { value: "streamMin" as const, label: `📋 stream min calls: ${c.taskStreamMinCalls}`, description: "calls of the same tool before a batch is recognized" },
97
+ { value: "streamTwin" as const, label: `📋 twin threshold: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "calls more similar than this = the same task repeated, not a batch" },
98
+ // ── 🔍 Detectors ────────────────────────────────────────────
99
+ { value: "text" as const, label: `📝 text: ${yn(c.detectTextLoops)}`, description: "detect repeated text messages" },
100
+ { value: "tool" as const, label: `🔧 tools: ${yn(c.detectToolLoops)}`, description: "detect repeated tool calls" },
101
+ { value: "think" as const, label: `🧠 thinking: ${yn(c.detectThinkingLoops)}`, description: "detect repeated internal reasoning" },
102
+ // ── 🧹 ──────────────────────────────────────────────────────
103
+ { value: "reset" as const, label: "🧹 reset state", description: "clear counters and history" },
99
104
  ]);
100
105
  if (!picked) return;
101
106
  switch (picked) {
@@ -105,29 +110,56 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
105
110
  ctx.ui.notify(`antiloop: ${c.enabled ? "ON" : "OFF"}`, "info");
106
111
  rt.updateStatus(ctx);
107
112
  break;
113
+ case "window": {
114
+ const v = await selectFrom(ctx, "⏳ window (messages to analyze)", [
115
+ { value: 5, label: "5" },
116
+ { value: 10, label: "🎯 10 (default)" },
117
+ { value: 15, label: "15" },
118
+ { value: 20, label: "20" },
119
+ ]);
120
+ if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
121
+ break;
122
+ }
123
+ case "notify":
124
+ c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
125
+ ctx.ui.notify(`notifications: ${yn(c.notifyOnDetection)}`, "info"); break;
126
+ case "footer":
127
+ c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
128
+ ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
129
+ rt.refreshFooter?.(ctx);
130
+ rt.updateStatus(ctx);
131
+ break;
132
+ case "shortcut": {
133
+ const v = await selectFrom(ctx, "⌨️ toggle shortcut", [
134
+ { value: "esc+a" as const, label: "⌨️ esc+a (default)", description: "press ESC then a" },
135
+ { value: "off" as const, label: "🚫 off" },
136
+ ]);
137
+ if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`shortcut: ${v}`, "info"); }
138
+ break;
139
+ }
108
140
  case "warn": {
109
- const v = await selectFrom(ctx, "warn threshold", [
110
- { value: 1, label: "1 (sensitive)" },
111
- { value: 2, label: "2 (default)" },
141
+ const v = await selectFrom(ctx, "⚠️ warn threshold", [
142
+ { value: 1, label: "1 (sensitive)" },
143
+ { value: 2, label: "🎯 2 (default)" },
112
144
  { value: 3, label: "3" },
113
- { value: 5, label: "5 (relaxed)" },
145
+ { value: 5, label: "🐢 5 (relaxed)" },
114
146
  ]);
115
147
  if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
116
148
  break;
117
149
  }
118
150
  case "force": {
119
- const v = await selectFrom(ctx, "force threshold", [
120
- { value: 2, label: "2 (sensitive)" },
121
- { value: 3, label: "3 (default)" },
151
+ const v = await selectFrom(ctx, "🛑 force break threshold", [
152
+ { value: 2, label: "2 (sensitive)" },
153
+ { value: 3, label: "🎯 3 (default)" },
122
154
  { value: 5, label: "5" },
123
- { value: 8, label: "8 (relaxed)" },
155
+ { value: 8, label: "🐢 8 (relaxed)" },
124
156
  ]);
125
- if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force: ${v}`, "info"); }
157
+ if (v !== undefined) { c.forceBreakThreshold = v; saveConfig(c); ctx.ui.notify(`force break: ${v}`, "info"); }
126
158
  break;
127
159
  }
128
160
  case "abort": {
129
- const v = await selectFrom(ctx, "abort threshold (0=off)", [
130
- { value: 0, label: "off" },
161
+ const v = await selectFrom(ctx, "🚨 abort threshold (0 = disabled)", [
162
+ { value: 0, label: "🚫 off" },
131
163
  { value: 5, label: "5" },
132
164
  { value: 8, label: "8" },
133
165
  { value: 10, label: "10" },
@@ -137,41 +169,41 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
137
169
  break;
138
170
  }
139
171
  case "sim": {
140
- const v = await selectFrom(ctx, "similarity", [
141
- { value: 0.5, label: "50% (sensitive)" },
172
+ const v = await selectFrom(ctx, "📏 text similarity", [
173
+ { value: 0.5, label: "50% (sensitive)" },
142
174
  { value: 0.6, label: "60%" },
143
175
  { value: 0.7, label: "70%" },
144
- { value: 0.75, label: "75% (default)" },
176
+ { value: 0.75, label: "🎯 75% (default)" },
145
177
  { value: 0.8, label: "80%" },
146
- { value: 0.9, label: "90% (relaxed)" },
178
+ { value: 0.9, label: "🐢 90% (relaxed)" },
147
179
  ]);
148
180
  if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
149
181
  break;
150
182
  }
151
183
  case "toolSim": {
152
- const v = await selectFrom(ctx, "tool similarity (args)", [
153
- { value: 0.99, label: "99% (strict)" },
154
- { value: 0.95, label: "95% (default)" },
184
+ const v = await selectFrom(ctx, "🔧 call similarity (arguments)", [
185
+ { value: 0.99, label: "99% (strict)" },
186
+ { value: 0.95, label: "🎯 95% (default)" },
155
187
  { value: 0.9, label: "90%" },
156
- { value: 0.8, label: "80% (sensitive)" },
188
+ { value: 0.8, label: "🐢 80% (sensitive)" },
157
189
  ]);
158
- if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`tool similarity: ${(v * 100).toFixed(0)}%`, "info"); }
190
+ if (v !== undefined) { c.toolSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`call similarity: ${(v * 100).toFixed(0)}%`, "info"); }
159
191
  break;
160
192
  }
161
193
  case "toolRepeat": {
162
- const v = await selectFrom(ctx, "tool repeat (prior occurrences)", [
163
- { value: 1, label: "1 (sensitive)" },
164
- { value: 2, label: "2 (default)" },
165
- { value: 3, label: "3 (relaxed)" },
194
+ const v = await selectFrom(ctx, "🔁 call repeats", [
195
+ { value: 1, label: "1 (sensitive)" },
196
+ { value: 2, label: "🎯 2 (default)" },
197
+ { value: 3, label: "🐢 3 (relaxed)" },
166
198
  ]);
167
- if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`tool repeat: ${v}+ prior`, "info"); }
199
+ if (v !== undefined) { c.minToolRepeatCount = v; saveConfig(c); ctx.ui.notify(`call repeats: ${v}+`, "info"); }
168
200
  break;
169
201
  }
170
202
  case "resultSim": {
171
- const v = await selectFrom(ctx, "result similarity (outcome veto)", [
172
- { value: 0.95, label: "95% (strict — only near-identical results count as same outcome)" },
173
- { value: 0.8, label: "80% (default)" },
174
- { value: 0.6, label: "60% (relaxed — tolerates more output noise)" },
203
+ const v = await selectFrom(ctx, "🧾 result similarity (progress veto)", [
204
+ { value: 0.95, label: "95% (strict — only near-identical results count as the same)" },
205
+ { value: 0.8, label: "🎯 80% (default)" },
206
+ { value: 0.6, label: "🐢 60% (relaxed — tolerates more output noise)" },
175
207
  ]);
176
208
  if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
177
209
  break;
@@ -180,32 +212,22 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
180
212
  c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
181
213
  ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
182
214
  case "streamMin": {
183
- const v = await selectFrom(ctx, "stream min calls", [
184
- { value: 2, label: "2 (sensitive)" },
185
- { value: 3, label: "3 (default)" },
215
+ const v = await selectFrom(ctx, "📋 stream min calls", [
216
+ { value: 2, label: "2 (sensitive)" },
217
+ { value: 3, label: "🎯 3 (default)" },
186
218
  { value: 4, label: "4" },
187
- { value: 5, label: "5 (conservative)" },
219
+ { value: 5, label: "🐢 5 (conservative)" },
188
220
  ]);
189
221
  if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`stream min calls: ${v}`, "info"); }
190
222
  break;
191
223
  }
192
224
  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)" },
225
+ const v = await selectFrom(ctx, "📋 twin threshold (arguments)", [
226
+ { value: 0.99, label: "🎯 99% (default — any real difference = distinct task)" },
227
+ { value: 0.95, label: "95% (near-identical arguments count as the same task)" },
228
+ { value: 0.9, label: "90% (more aggressive loop detection)" },
197
229
  ]);
198
- if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`stream twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
199
- break;
200
- }
201
- case "window": {
202
- const v = await selectFrom(ctx, "window", [
203
- { value: 5, label: "5" },
204
- { value: 10, label: "10 (default)" },
205
- { value: 15, label: "15" },
206
- { value: 20, label: "20" },
207
- ]);
208
- if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`window: ${v}`, "info"); }
230
+ if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
209
231
  break;
210
232
  }
211
233
  case "text":
@@ -213,31 +235,14 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
213
235
  ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
214
236
  case "tool":
215
237
  c.detectToolLoops = !c.detectToolLoops; saveConfig(c);
216
- ctx.ui.notify(`tool: ${yn(c.detectToolLoops)}`, "info"); break;
238
+ ctx.ui.notify(`tools: ${yn(c.detectToolLoops)}`, "info"); break;
217
239
  case "think":
218
240
  c.detectThinkingLoops = !c.detectThinkingLoops; saveConfig(c);
219
- ctx.ui.notify(`think: ${yn(c.detectThinkingLoops)}`, "info"); break;
220
- case "notify":
221
- c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
222
- ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
223
- case "footer":
224
- c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
225
- ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
226
- rt.refreshFooter?.(ctx);
227
- rt.updateStatus(ctx);
228
- break;
229
- case "shortcut": {
230
- const v = await selectFrom(ctx, "toggle shortcut", [
231
- { value: "esc+a" as const, label: "esc+a (default)", description: "press ESC then a to toggle" },
232
- { value: "off" as const, label: "off", description: "disable keyboard toggle" },
233
- ]);
234
- if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`toggle shortcut: ${v}`, "info"); }
235
- break;
236
- }
241
+ ctx.ui.notify(`thinking: ${yn(c.detectThinkingLoops)}`, "info"); break;
237
242
  case "reset":
238
243
  resetState(rt.state);
239
244
  rt.pendingIntervention = null;
240
- ctx.ui.notify("reset", "info");
245
+ ctx.ui.notify("🧹 state reset", "info");
241
246
  rt.updateStatus(ctx);
242
247
  break;
243
248
  }
@@ -253,7 +258,7 @@ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void>
253
258
  label: `[${d.type}] ${d.description}`,
254
259
  description: `${(d.similarity * 100).toFixed(0)}% · ${formatDuration(Date.now() - d.timestamp)} ago`,
255
260
  }));
256
- await selectFrom(ctx, `detections (${rt.state.detections.length} total)`, items);
261
+ await selectFrom(ctx, `🕵️ detections (${rt.state.detections.length} total)`, items);
257
262
  }
258
263
 
259
264
  export function resetState(state: AntiloopState): void {
package/src/detect.ts CHANGED
@@ -59,11 +59,19 @@ function similarity(a: string, b: string): number {
59
59
  * Stable against PID / timestamp noise at the tail of command output.
60
60
  */
61
61
  export function resultFingerprint(
62
- parts: Array<{ type: string; text?: string }>,
62
+ parts: unknown,
63
63
  isError: boolean,
64
64
  ): string | undefined {
65
65
  let text = "";
66
- for (const p of parts) if (p.type === "text" && typeof p.text === "string") text += p.text;
66
+ if (typeof parts === "string") {
67
+ text = parts;
68
+ } else if (Array.isArray(parts)) {
69
+ for (const p of parts) {
70
+ if (p && typeof p === "object" && "type" in p && p.type === "text" && typeof (p as any).text === "string") {
71
+ text += (p as any).text;
72
+ }
73
+ }
74
+ }
67
75
  const norm = normalizeText(text);
68
76
  if (!norm.length) return undefined;
69
77
  return `${isError ? "err" : "ok"}|${norm.slice(-400)}`;
@@ -72,9 +80,6 @@ export function resultFingerprint(
72
80
  /** Same outcome = identical fingerprint, or high similarity of the tails. */
73
81
  function sameOutcome(a: string, b: string, threshold: number): boolean {
74
82
  if (a === b) return true;
75
- // Fingerprints are already normalized + capped, so short ones can be
76
- // compared with edit distance directly — similarity() bails under 50 chars
77
- // and would wrongly veto small outputs with harmless noise (PIDs, times).
78
83
  if (a.length < 100 && b.length < 100) {
79
84
  const max = Math.max(a.length, b.length);
80
85
  const s = max ? 1 - levenshtein(a, b) / max : 1;
@@ -91,14 +96,10 @@ function toolCallsSimilar(
91
96
  resultThreshold: number,
92
97
  ): boolean {
93
98
  if (c1.length !== c2.length) return false;
94
- // Empty call lists carry no repetition evidence — never treat them as a match.
95
99
  if (!c1.length) return false;
96
100
  for (let i = 0; i < c1.length; i++) {
97
101
  if (c1[i].name !== c2[i].name) return false;
98
- if (similarity(c1[i].args, c2[i].args) < threshold) return false;
99
- // Result veto: the same command producing a different outcome is
100
- // progress (a retry that fixed the problem), not a loop. Only applies
101
- // when both runs actually captured a result.
102
+ if (!argsTwin(c1[i].args, c2[i].args, threshold)) return false;
102
103
  const r1 = c1[i].result;
103
104
  const r2 = c2[i].result;
104
105
  if (r1 && r2 && !sameOutcome(r1, r2, resultThreshold)) {
@@ -220,7 +221,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
220
221
  const last = opens[opens.length - 1].o;
221
222
  let n = 0;
222
223
  for (let i = 0; i < opens.length - 1; i++) {
223
- if (similarity(last, opens[i].o) > 0.9) n++;
224
+ if (argsTwin(last, opens[i].o, 0.9)) n++;
224
225
  }
225
226
  if (n >= 2 && !allBatch([msgs.length - 1])) {
226
227
  out.push({
package/src/index.ts CHANGED
@@ -109,14 +109,12 @@ export default function antiloopExtension(pi: ExtensionAPI) {
109
109
  function installFooter(ctx: ExtensionContext): void {
110
110
  if (!config.interactiveFooter || ctx.mode !== "tui") {
111
111
  ctx.ui.setFooter(undefined);
112
+ activeTui = undefined;
112
113
  return;
113
114
  }
114
115
  ctx.ui.setFooter((tui, theme, footerData) => {
115
116
  activeTui = tui;
116
117
 
117
- // Keyboard toggle from raw terminal input: escape followed by `a`.
118
- // We never consume the input, so typing is unaffected — ESC alone
119
- // passes through, and an accidental toggle is easily reversed.
120
118
  let pendingEsc = false;
121
119
  const shortcut = config.toggleShortcut;
122
120
  const unsubInput =
@@ -128,12 +126,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
128
126
  pendingEsc = true;
129
127
  return undefined;
130
128
  }
131
- if (pendingEsc && data === "a") {
129
+ if (pendingEsc) {
132
130
  pendingEsc = false;
133
- toggleEnabled(ctx);
131
+ if (data === "a") {
132
+ toggleEnabled(ctx);
133
+ }
134
134
  return undefined;
135
135
  }
136
- pendingEsc = false;
137
136
  return undefined;
138
137
  });
139
138
 
@@ -146,7 +145,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
146
145
  render(width: number): string[] {
147
146
  const lines: string[] = [];
148
147
 
149
- // Line 1: the spec indicator + toggle hint.
150
148
  const status = antiloopStatusText();
151
149
  const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
152
150
  const hint =
@@ -155,7 +153,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
155
153
  : theme.fg("dim", " [/antiloop] toggle");
156
154
  lines.push(truncateToWidth(colored + hint, width));
157
155
 
158
- // Line 2 (only while detecting): level + consecutive + last reason.
159
156
  if (state.currentLevel > 0) {
160
157
  const last = state.detections[state.detections.length - 1];
161
158
  const desc = last ? ` · ${last.description}` : "";
@@ -167,7 +164,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
167
164
  );
168
165
  }
169
166
 
170
- // Line 3: built-in footer data preserved (dim).
171
167
  let info = formatCwd(ctx.cwd);
172
168
  const branch = footerData.getGitBranch();
173
169
  if (branch) info += ` (${branch})`;
@@ -177,7 +173,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
177
173
  if (ctx.model) info += ` · ${ctx.model.id}`;
178
174
  lines.push(truncateToWidth(theme.fg("dim", info), width));
179
175
 
180
- // Line 4 (only when other extensions set statuses): keep them visible.
181
176
  const others = Array.from(footerData.getExtensionStatuses().entries())
182
177
  .filter(([k]) => k !== "antiloop")
183
178
  .map(([, v]) => v);
@@ -206,7 +201,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
206
201
  }
207
202
  }
208
203
 
209
- // Track the call with its id so turn_end can attach the execution result.
210
204
  const toolCalls: TrackedToolCall[] = [];
211
205
  if (Array.isArray(msg.content)) {
212
206
  for (const p of msg.content) {
@@ -268,19 +262,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
268
262
  const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
269
263
 
270
264
  const last = state.recentMessages[state.recentMessages.length - 1];
271
- // A turn whose assistant message wasn't tracked (short text, no tools)
272
- // must not re-run detection on the previous message — skip it.
273
265
  if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
274
266
  updateStatus(ctx);
275
267
  return;
276
268
  }
277
269
  state.lastDetectedTurnIndex = last.turnIndex;
278
270
 
279
- // Attach execution results to the message that just finished its turn.
280
- // Detection runs here (not at message_end) because the results — the
281
- // signal that distinguishes "stuck loop" from "making progress" — only
282
- // exist after the tools have executed.
283
- if (last.toolCalls?.length && event.toolResults.length) {
271
+ if (last.toolCalls?.length && Array.isArray(event?.toolResults)) {
284
272
  const byId = new Map<string, string | undefined>();
285
273
  for (const tr of event.toolResults) byId.set(tr.toolCallId, resultFingerprint(tr.content, tr.isError));
286
274
  for (const tc of last.toolCalls) {