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 +29 -17
- package/package.json +1 -1
- package/src/commands.ts +94 -89
- package/src/detect.ts +12 -11
- package/src/index.ts +6 -18
package/README.md
CHANGED
|
@@ -106,23 +106,35 @@ Recent detections:
|
|
|
106
106
|
|
|
107
107
|
### `/antiloop config`
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
-
|
|
113
|
-
- **
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
-
|
|
124
|
-
- **
|
|
125
|
-
-
|
|
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 off — key 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
|
+
"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
|
-
|
|
81
|
-
{ value: "
|
|
82
|
-
{ value: "
|
|
83
|
-
{ value: "
|
|
84
|
-
{ value: "
|
|
85
|
-
{ value: "
|
|
86
|
-
|
|
87
|
-
{ value: "
|
|
88
|
-
{ value: "
|
|
89
|
-
{ value: "
|
|
90
|
-
{ value: "
|
|
91
|
-
{ value: "
|
|
92
|
-
{ value: "
|
|
93
|
-
{ value: "
|
|
94
|
-
|
|
95
|
-
{ value: "
|
|
96
|
-
{ value: "
|
|
97
|
-
{ value: "
|
|
98
|
-
|
|
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=
|
|
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, "
|
|
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(`
|
|
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, "
|
|
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(`
|
|
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 (
|
|
172
|
-
{ value: 0.95, label: "95% (strict — only near-identical results count as same
|
|
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, "
|
|
194
|
-
{ value: 0.99, label: "99% (default — any real difference = distinct task)" },
|
|
195
|
-
{ value: 0.95, label: "95% (near-identical
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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,
|
|
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:
|
|
62
|
+
parts: unknown,
|
|
63
63
|
isError: boolean,
|
|
64
64
|
): string | undefined {
|
|
65
65
|
let text = "";
|
|
66
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
129
|
+
if (pendingEsc) {
|
|
132
130
|
pendingEsc = false;
|
|
133
|
-
|
|
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
|
-
|
|
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) {
|