pi-antiloop 1.3.1 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
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 ? "🟢 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/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) {