pi-antiloop 1.3.0 → 1.4.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
@@ -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/offrecognize 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
+ Menú interactivo agrupado, con el valor actual en cada opción:
110
+
111
+ **🎛️ General**
112
+ - **🟢/🔴 activar/desactivar** — enciende o apaga la detección
113
+ - **⏳ ventana** `5 / 10 / 15 / 20` cuántos mensajes recientes se analizan (por defecto 10)
114
+ - **🔔 avisos** — on/off avisa cuando detecta un bucle
115
+ - **📊 pie interactivo** — on/off experimental: reemplaza el pie de pantalla de pi y captura el teclado para el atajo (si da problemas de rendimiento, déjalo en off)
116
+ - **⌨️ atajo** — `esc+a` o desactivadopulsación para activar/apagar sin teclear nada
117
+
118
+ **🎯 Detección**
119
+ - **⚠️ umbral de aviso**`1 / 2 / 3 / 5` repeticiones antes de avisar (por defecto 2)
120
+ - **🛑 umbral de corte** — `2 / 3 / 5 / 8` — repeticiones antes de forzar un cambio de rumbo (por defecto 3)
121
+ - **🚨 umbral de aborto** — `desactivado / 5 / 8 / 10 / 15` repeticiones antes de abortar (0 = desactivado)
122
+ - **📏 parecido mínimo** — `50 / 60 / 70 / 75 / 80 / 90%` qué tan parecidos deben ser dos mensajes para contar como bucle (por defecto 75%)
123
+ - **🔧 parecido de llamadas** — `99 / 95 / 90 / 80%` — qué tan idénticas deben ser las llamadas para contar como la misma (por defecto 95%: solo repeticiones casi idénticas son bucle)
124
+ - **🔁 repeticiones de llamada** — `1 / 2 / 3` — cuántas veces se repite la misma llamada antes de marcarla (por defecto 2)
125
+ - **🧾 parecido de resultados** — `95 / 80 / 60%` — qué tan parecidos deben ser los resultados para contar como el *mismo resultado*; si un comando repetido empieza a dar otro resultado, es progreso y no bucle (por defecto 80%)
126
+
127
+ **📋 Lotes de tareas** — el trabajo en lote (punched_log, plan_manager, …) es N tareas de un tipo, no un bucle
128
+ - **📋 lotes de tareas** — on/off — reconoce ese trabajo en lote y se queda en silencio
129
+ - **📋 llamadas mínimas** — `2 / 3 / 4 / 5` — llamadas de la misma herramienta antes de reconocer un lote (por defecto 3)
130
+ - **📋 gemelos** — `99 / 95 / 90%` — llamadas más parecidas que esto cuentan como la *misma tarea* repetida; un gemelo invalida el lote y vuelve a la detección normal (por defecto 99%)
131
+
132
+ **🔍 Detectores**
133
+ - **📝 texto** — on/off — detecta mensajes de texto repetidos
134
+ - **🔧 herramientas** — on/off — detecta llamadas a herramientas repetidas
135
+ - **🧠 pensamiento** — on/off — detecta bloques de razonamiento repetidos
136
+
137
+ **🧹 reiniciar estado** — borra contadores e historial
126
138
 
127
139
  ### `/antiloop log`
128
140
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-antiloop",
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.",
3
+ "version": "1.4.0",
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",
7
7
  "antiloop",
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 ? "🟢 desactivar" : "🔴 activar", description: "enciende o apaga la detección" },
82
+ { value: "window" as const, label: `⏳ ventana: ${c.detectionWindow} msgs`, description: "cuántos mensajes recientes se analizan" },
83
+ { value: "notify" as const, label: `🔔 avisos: ${yn(c.notifyOnDetection)}`, description: "avisa cuando detecta un bucle" },
84
+ { value: "footer" as const, label: `📊 pie interactivo: ${yn(c.interactiveFooter)}`, description: "experimental: reemplaza el pie de pi y captura el teclado (si da problemas, déjalo en off)" },
85
+ { value: "shortcut" as const, label: `⌨️ atajo: ${c.toggleShortcut}`, description: "esc+a o desactivado" },
86
+ // ── 🎯 Detección ────────────────────────────────────────────
87
+ { value: "warn" as const, label: `⚠️ umbral aviso: ${c.warningThreshold}`, description: "repeticiones antes de avisar" },
88
+ { value: "force" as const, label: `🛑 umbral corte: ${c.forceBreakThreshold}`, description: "repeticiones antes de forzar un cambio de rumbo" },
89
+ { value: "abort" as const, label: `🚨 umbral abortar: ${c.abortThreshold || "off"}`, description: "repeticiones antes de abortar (0 = desactivado)" },
90
+ { value: "sim" as const, label: `📏 parecido mínimo: ${(c.similarityThreshold * 100).toFixed(0)}%`, description: "qué tan parecidos deben ser dos mensajes para contar como bucle" },
91
+ { value: "toolSim" as const, label: `🔧 parecido de llamadas: ${(c.toolSimilarityThreshold * 100).toFixed(0)}%`, description: "qué tan idénticas deben ser las llamadas para contar como la misma" },
92
+ { value: "toolRepeat" as const, label: `🔁 repeticiones de llamada: ${c.minToolRepeatCount}+`, description: "cuántas veces se repite la misma llamada antes de marcarla" },
93
+ { value: "resultSim" as const, label: `🧾 parecido de resultados: ${(c.resultSimilarityThreshold * 100).toFixed(0)}%`, description: "mismo comando + resultado distinto = progreso, no bucle" },
94
+ // ── 📋 Task streams ─────────────────────────────────────────
95
+ { value: "streams" as const, label: `📋 lotes de tareas: ${yn(c.detectTaskStreams)}`, description: "trabajo en lote (punched_log / plan_manager / …) no es un bucle" },
96
+ { value: "streamMin" as const, label: `📋 llamadas mínimas: ${c.taskStreamMinCalls}`, description: "llamadas de la misma herramienta antes de reconocer un lote" },
97
+ { value: "streamTwin" as const, label: `📋 gemelos: ${(c.taskStreamTwinThreshold * 100).toFixed(0)}%`, description: "llamadas más parecidas que esto = la misma tarea repetida, no un lote" },
98
+ // ── 🔍 Detectores ───────────────────────────────────────────
99
+ { value: "text" as const, label: `📝 texto: ${yn(c.detectTextLoops)}`, description: "detecta mensajes de texto repetidos" },
100
+ { value: "tool" as const, label: `🔧 herramientas: ${yn(c.detectToolLoops)}`, description: "detecta llamadas repetidas a herramientas" },
101
+ { value: "think" as const, label: `🧠 pensamiento: ${yn(c.detectThinkingLoops)}`, description: "detecta razonamiento interno repetido" },
102
+ // ── 🧹 ──────────────────────────────────────────────────────
103
+ { value: "reset" as const, label: "🧹 reiniciar estado", description: "borra contadores e historial" },
99
104
  ]);
100
105
  if (!picked) return;
101
106
  switch (picked) {
@@ -105,139 +110,139 @@ 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, "⏳ ventana (mensajes a analizar)", [
115
+ { value: 5, label: "5" },
116
+ { value: 10, label: "10 (por defecto)" },
117
+ { value: 15, label: "15" },
118
+ { value: 20, label: "20" },
119
+ ]);
120
+ if (v !== undefined) { c.detectionWindow = v; saveConfig(c); ctx.ui.notify(`ventana: ${v}`, "info"); }
121
+ break;
122
+ }
123
+ case "notify":
124
+ c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
125
+ ctx.ui.notify(`avisos: ${yn(c.notifyOnDetection)}`, "info"); break;
126
+ case "footer":
127
+ c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
128
+ ctx.ui.notify(`pie interactivo: ${yn(c.interactiveFooter)}`, "info");
129
+ rt.refreshFooter?.(ctx);
130
+ rt.updateStatus(ctx);
131
+ break;
132
+ case "shortcut": {
133
+ const v = await selectFrom(ctx, "⌨️ atajo para activar/apagar", [
134
+ { value: "esc+a" as const, label: "⌨️ esc+a (por defecto)", description: "pulsa ESC y luego a" },
135
+ { value: "off" as const, label: "🚫 desactivado" },
136
+ ]);
137
+ if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`atajo: ${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, "⚠️ umbral de aviso", [
142
+ { value: 1, label: "1 (sensible)" },
143
+ { value: 2, label: "🎯 2 (por defecto)" },
112
144
  { value: 3, label: "3" },
113
- { value: 5, label: "5 (relaxed)" },
145
+ { value: 5, label: "🐢 5 (relajado)" },
114
146
  ]);
115
- if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`warn: ${v}`, "info"); }
147
+ if (v !== undefined) { c.warningThreshold = v; saveConfig(c); ctx.ui.notify(`aviso: ${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, "🛑 umbral de corte", [
152
+ { value: 2, label: "2 (sensible)" },
153
+ { value: 3, label: "🎯 3 (por defecto)" },
122
154
  { value: 5, label: "5" },
123
- { value: 8, label: "8 (relaxed)" },
155
+ { value: 8, label: "🐢 8 (relajado)" },
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(`corte: ${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, "🚨 umbral de aborto (0 = desactivado)", [
162
+ { value: 0, label: "🚫 desactivado" },
131
163
  { value: 5, label: "5" },
132
164
  { value: 8, label: "8" },
133
165
  { value: 10, label: "10" },
134
166
  { value: 15, label: "15" },
135
167
  ]);
136
- if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`abort: ${v || "off"}`, "info"); }
168
+ if (v !== undefined) { c.abortThreshold = v; saveConfig(c); ctx.ui.notify(`aborto: ${v || "desactivado"}`, "info"); }
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, "📏 parecido mínimo entre mensajes", [
173
+ { value: 0.5, label: "50% (sensible)" },
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% (por defecto)" },
145
177
  { value: 0.8, label: "80%" },
146
- { value: 0.9, label: "90% (relaxed)" },
178
+ { value: 0.9, label: "🐢 90% (relajado)" },
147
179
  ]);
148
- if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`similarity: ${(v * 100).toFixed(0)}%`, "info"); }
180
+ if (v !== undefined) { c.similarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido: ${(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, "🔧 parecido de las llamadas (argumentos)", [
185
+ { value: 0.99, label: "99% (estricto)" },
186
+ { value: 0.95, label: "🎯 95% (por defecto)" },
155
187
  { value: 0.9, label: "90%" },
156
- { value: 0.8, label: "80% (sensitive)" },
188
+ { value: 0.8, label: "🐢 80% (sensible)" },
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(`parecido de llamadas: ${(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, "🔁 repeticiones de la misma llamada", [
195
+ { value: 1, label: "1 (sensible)" },
196
+ { value: 2, label: "🎯 2 (por defecto)" },
197
+ { value: 3, label: "🐢 3 (relajado)" },
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(`repeticiones: ${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% (strictonly near-identical results count as same outcome)" },
173
- { value: 0.8, label: "80% (default)" },
174
- { value: 0.6, label: "60% (relaxedtolerates more output noise)" },
203
+ const v = await selectFrom(ctx, "🧾 parecido de resultados (veto de progreso)", [
204
+ { value: 0.95, label: "95% (estrictosolo resultados casi idénticos cuentan como igual)" },
205
+ { value: 0.8, label: "🎯 80% (por defecto)" },
206
+ { value: 0.6, label: "🐢 60% (relajadotolera más ruido de salida)" },
175
207
  ]);
176
- if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
208
+ if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`parecido de resultados: ${(v * 100).toFixed(0)}%`, "info"); }
177
209
  break;
178
210
  }
179
211
  case "streams":
180
212
  c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
181
- ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
213
+ ctx.ui.notify(`lotes de tareas: ${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, "📋 llamadas mínimas para reconocer un lote", [
216
+ { value: 2, label: "2 (sensible)" },
217
+ { value: 3, label: "🎯 3 (por defecto)" },
186
218
  { value: 4, label: "4" },
187
- { value: 5, label: "5 (conservative)" },
219
+ { value: 5, label: "🐢 5 (conservador)" },
188
220
  ]);
189
- if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`stream min calls: ${v}`, "info"); }
221
+ if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`llamadas mínimas: ${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% (defaultany 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, "📋 umbral de gemelos (argumentos)", [
226
+ { value: 0.99, label: "🎯 99% (por defecto cualquier diferencia real = tarea distinta)" },
227
+ { value: 0.95, label: "95% (argumentos casi idénticos cuentan como la misma tarea)" },
228
+ { value: 0.9, label: "90% (detección de bucles más agresiva)" },
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(`gemelos: ${(v * 100).toFixed(0)}%`, "info"); }
209
231
  break;
210
232
  }
211
233
  case "text":
212
234
  c.detectTextLoops = !c.detectTextLoops; saveConfig(c);
213
- ctx.ui.notify(`text: ${yn(c.detectTextLoops)}`, "info"); break;
235
+ ctx.ui.notify(`texto: ${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(`herramientas: ${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(`pensamiento: ${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("🧹 estado reiniciado", "info");
241
246
  rt.updateStatus(ctx);
242
247
  break;
243
248
  }
@@ -245,7 +250,7 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
245
250
 
246
251
  async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void> {
247
252
  if (!rt.state.detections.length) {
248
- ctx.ui.notify("no detections this session", "info");
253
+ ctx.ui.notify("no hay detecciones en esta sesión", "info");
249
254
  return;
250
255
  }
251
256
  const items = rt.state.detections.slice(-30).reverse().map((d) => ({
@@ -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, `🕵️ detecciones (${rt.state.detections.length} en total)`, items);
257
262
  }
258
263
 
259
264
  export function resetState(state: AntiloopState): void {
package/src/config.ts CHANGED
@@ -25,7 +25,11 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
25
25
  notifyOnDetection: true,
26
26
  maxHistoryEntries: 100,
27
27
  detectionWindow: 10,
28
- interactiveFooter: true,
28
+ // Custom footer replaces the built-in one while active. Off by default:
29
+ // pi restores its own footer right after boot anyway, so installing a
30
+ // look-alike at session_start only produced a transient duplicated-footer
31
+ // artifact. The 🔄 on/off status still shows via setStatus in the built-in footer.
32
+ interactiveFooter: false,
29
33
  toggleShortcut: "esc+a",
30
34
  };
31
35
 
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
@@ -43,19 +43,9 @@ export default function antiloopExtension(pi: ExtensionAPI) {
43
43
  /** TUI handle for forcing footer re-renders (set by the footer factory). */
44
44
  let activeTui: { requestRender(force?: boolean): void } | undefined;
45
45
 
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
+ /** Status text shown in the footer: "🔄 a:on" / "🔄 a:off" per spec. */
48
47
  function antiloopStatusText(): string {
49
- if (!config.enabled) return "🔄 antiloop(off)";
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;
48
+ return config.enabled ? "🔄 a:on" : "🔄 a:off";
59
49
  }
60
50
 
61
51
  function updateStatus(ctx: ExtensionContext): void {
@@ -119,14 +109,12 @@ export default function antiloopExtension(pi: ExtensionAPI) {
119
109
  function installFooter(ctx: ExtensionContext): void {
120
110
  if (!config.interactiveFooter || ctx.mode !== "tui") {
121
111
  ctx.ui.setFooter(undefined);
112
+ activeTui = undefined;
122
113
  return;
123
114
  }
124
115
  ctx.ui.setFooter((tui, theme, footerData) => {
125
116
  activeTui = tui;
126
117
 
127
- // Keyboard toggle from raw terminal input: escape followed by `a`.
128
- // We never consume the input, so typing is unaffected — ESC alone
129
- // passes through, and an accidental toggle is easily reversed.
130
118
  let pendingEsc = false;
131
119
  const shortcut = config.toggleShortcut;
132
120
  const unsubInput =
@@ -138,12 +126,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
138
126
  pendingEsc = true;
139
127
  return undefined;
140
128
  }
141
- if (pendingEsc && data === "a") {
129
+ if (pendingEsc) {
142
130
  pendingEsc = false;
143
- toggleEnabled(ctx);
131
+ if (data === "a") {
132
+ toggleEnabled(ctx);
133
+ }
144
134
  return undefined;
145
135
  }
146
- pendingEsc = false;
147
136
  return undefined;
148
137
  });
149
138
 
@@ -156,7 +145,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
156
145
  render(width: number): string[] {
157
146
  const lines: string[] = [];
158
147
 
159
- // Line 1: the spec indicator + toggle hint.
160
148
  const status = antiloopStatusText();
161
149
  const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
162
150
  const hint =
@@ -165,7 +153,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
165
153
  : theme.fg("dim", " [/antiloop] toggle");
166
154
  lines.push(truncateToWidth(colored + hint, width));
167
155
 
168
- // Line 2 (only while detecting): level + consecutive + last reason.
169
156
  if (state.currentLevel > 0) {
170
157
  const last = state.detections[state.detections.length - 1];
171
158
  const desc = last ? ` · ${last.description}` : "";
@@ -177,7 +164,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
177
164
  );
178
165
  }
179
166
 
180
- // Line 3: built-in footer data preserved (dim).
181
167
  let info = formatCwd(ctx.cwd);
182
168
  const branch = footerData.getGitBranch();
183
169
  if (branch) info += ` (${branch})`;
@@ -187,7 +173,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
187
173
  if (ctx.model) info += ` · ${ctx.model.id}`;
188
174
  lines.push(truncateToWidth(theme.fg("dim", info), width));
189
175
 
190
- // Line 4 (only when other extensions set statuses): keep them visible.
191
176
  const others = Array.from(footerData.getExtensionStatuses().entries())
192
177
  .filter(([k]) => k !== "antiloop")
193
178
  .map(([, v]) => v);
@@ -216,7 +201,6 @@ export default function antiloopExtension(pi: ExtensionAPI) {
216
201
  }
217
202
  }
218
203
 
219
- // Track the call with its id so turn_end can attach the execution result.
220
204
  const toolCalls: TrackedToolCall[] = [];
221
205
  if (Array.isArray(msg.content)) {
222
206
  for (const p of msg.content) {
@@ -278,19 +262,13 @@ export default function antiloopExtension(pi: ExtensionAPI) {
278
262
  const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
279
263
 
280
264
  const last = state.recentMessages[state.recentMessages.length - 1];
281
- // A turn whose assistant message wasn't tracked (short text, no tools)
282
- // must not re-run detection on the previous message — skip it.
283
265
  if (!last || last.turnIndex === state.lastDetectedTurnIndex) {
284
266
  updateStatus(ctx);
285
267
  return;
286
268
  }
287
269
  state.lastDetectedTurnIndex = last.turnIndex;
288
270
 
289
- // Attach execution results to the message that just finished its turn.
290
- // Detection runs here (not at message_end) because the results — the
291
- // signal that distinguishes "stuck loop" from "making progress" — only
292
- // exist after the tools have executed.
293
- if (last.toolCalls?.length && event.toolResults.length) {
271
+ if (last.toolCalls?.length && Array.isArray(event?.toolResults)) {
294
272
  const byId = new Map<string, string | undefined>();
295
273
  for (const tr of event.toolResults) byId.set(tr.toolCallId, resultFingerprint(tr.content, tr.isError));
296
274
  for (const tc of last.toolCalls) {