pi-antiloop 1.1.0 → 1.2.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
@@ -16,7 +16,7 @@
16
16
  - **Progressive intervention** — `warning` reminds the model to vary its approach; `force break` injects explicit anti-loop instructions and modifies context; `abort` stops the run entirely
17
17
  - **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
18
18
  - **Sliding window** — only the last N messages are compared, so detection is O(N) in the window size, not in the full session
19
- - **Live status bar** — `🔄 antiloop`, `⚠️ antiloop(N)`, `🛑 antiloop(N)`, `🚨 antiloop(N)` reflect the current intervention level
19
+ - **Live footer indicator** — `🔄 antiloop(on|off)` in the footer, per spec, with the current level (`⚠️/🛑/🚨`) and consecutive count; an interactive TUI footer adds a keyboard toggle (`esc+a` by default, configurable/off) and preserves the built-in footer's pwd/branch/context/model info
20
20
  - **Detection log** — timestamped history with similarity scores, filterable through the native pi menu
21
21
  - **Self-test** — `/antiloop test` runs built-in cases to verify the similarity engine is calibrated
22
22
  - **User input softens detection** — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
@@ -161,7 +161,7 @@ Each detected pair becomes a `LoopDetection { type, similarity, messageIndices,
161
161
  | Level | Trigger | Behavior |
162
162
  |-------|---------|----------|
163
163
  | 0 (no loop) | — | Silent — passes the message through |
164
- | 1 (warning) | `consecutiveDetections >= warningThreshold` | Injects a soft reminder asking the model to vary its approach |
164
+ | 1 (warning) | `consecutiveDetections >= warningThreshold` | Notifies the user (`⚠️`) no message is injected into the conversation, so the model's generation is never interrupted by the warning itself |
165
165
  | 2 (force break) | `consecutiveDetections >= forceBreakThreshold` | Injects mandatory anti-loop instructions + appends a context message to the last assistant message |
166
166
  | 3 (abort) | `consecutiveDetections >= abortThreshold` | (Disabled by default) Surfaces an error asking the user for new instructions |
167
167
 
@@ -231,7 +231,9 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
231
231
  "detectTextLoops": true,
232
232
  "notifyOnDetection": true,
233
233
  "maxHistoryEntries": 100,
234
- "detectionWindow": 10
234
+ "detectionWindow": 10,
235
+ "interactiveFooter": true,
236
+ "toggleShortcut": "esc+a"
235
237
  }
236
238
  ```
237
239
 
@@ -251,6 +253,8 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
251
253
  | `notifyOnDetection` | `true` | Show a notification on every detection |
252
254
  | `maxHistoryEntries` | `100` | Max detection history entries |
253
255
  | `detectionWindow` | `10` | Number of recent messages to analyze |
256
+ | `interactiveFooter` | `true` | TUI footer replaces the built-in one with an antiloop indicator + toggle shortcut (set `false` to keep the built-in footer and only the `setStatus` line) |
257
+ | `toggleShortcut` | `esc+a` | Key sequence that toggles antiloop from the footer (`esc+a` or `off`). The input is never consumed, so typing is unaffected |
254
258
 
255
259
  ## Best Practices
256
260
 
@@ -286,8 +290,8 @@ Modular extension with zero external dependencies (only pi's bundled `@earendil-
286
290
  - **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
287
291
  - **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
288
292
  - **Early bail** — short messages and empty tool calls skip similarity computation entirely
289
- - **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` for the persistent status bar
290
- - **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention), `context` (modify context in force-break mode), `session_start` (load config + reset)
293
+ - **TUI integration** — uses `ctx.ui.select` for the config menu and the log viewer; `ctx.ui.notify` for state notifications; `ctx.ui.setStatus` + a custom `ctx.ui.setFooter` component for the persistent footer indicator, live level info, and the `esc+a` keyboard toggle (`ctx.ui.onTerminalInput`, never consumes input)
294
+ - **Hooks** — `message_end` (track messages + tool call ids), `turn_end` (attach result fingerprints + detect), `input` (decay), `before_agent_start` (inject intervention — force/abort only), `context` (modify context in force-break mode), `session_start` (load config + install footer + reset), `session_shutdown` (restore built-in footer)
291
295
 
292
296
  ## License
293
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
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.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/commands.ts CHANGED
@@ -61,6 +61,7 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
61
61
  ` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
62
62
  "",
63
63
  `detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
64
+ `footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
64
65
  ];
65
66
  if (recent.length) {
66
67
  lines.push("", "recent:");
@@ -85,6 +86,8 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
85
86
  { value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
86
87
  { value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
87
88
  { value: "notify" as const, label: `notify: ${yn(c.notifyOnDetection)}` },
89
+ { value: "footer" as const, label: `interactive footer: ${yn(c.interactiveFooter)}`, description: "TUI footer with toggle shortcut" },
90
+ { value: "shortcut" as const, label: `toggle shortcut: ${c.toggleShortcut}`, description: "esc+a or off" },
88
91
  { value: "reset" as const, label: "reset state" },
89
92
  ]);
90
93
  if (!picked) return;
@@ -188,6 +191,20 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
188
191
  case "notify":
189
192
  c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
190
193
  ctx.ui.notify(`notify: ${yn(c.notifyOnDetection)}`, "info"); break;
194
+ case "footer":
195
+ c.interactiveFooter = !c.interactiveFooter; saveConfig(c);
196
+ ctx.ui.notify(`interactive footer: ${yn(c.interactiveFooter)}`, "info");
197
+ rt.refreshFooter?.(ctx);
198
+ rt.updateStatus(ctx);
199
+ break;
200
+ case "shortcut": {
201
+ const v = await selectFrom(ctx, "toggle shortcut", [
202
+ { value: "esc+a" as const, label: "esc+a (default)", description: "press ESC then a to toggle" },
203
+ { value: "off" as const, label: "off", description: "disable keyboard toggle" },
204
+ ]);
205
+ if (v !== undefined) { c.toggleShortcut = v; saveConfig(c); rt.refreshFooter?.(ctx); ctx.ui.notify(`toggle shortcut: ${v}`, "info"); }
206
+ break;
207
+ }
191
208
  case "reset":
192
209
  resetState(rt.state);
193
210
  rt.pendingIntervention = null;
package/src/config.ts CHANGED
@@ -22,6 +22,8 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
22
22
  notifyOnDetection: true,
23
23
  maxHistoryEntries: 100,
24
24
  detectionWindow: 10,
25
+ interactiveFooter: true,
26
+ toggleShortcut: "esc+a",
25
27
  };
26
28
 
27
29
  export function getConfigPath(): string {
package/src/index.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * antiloop — detect reasoning loops and intervene.
3
- * Hooks: message_end, input, before_agent_start, context, turn_end, session_start.
3
+ * Hooks: message_end, input, before_agent_start, context, turn_end, session_start, session_shutdown.
4
4
  * Commands: /antiloop [enable|disable|status|config|log|reset|test]
5
5
  */
6
6
 
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
- import { loadConfig } from "./config.ts";
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { loadConfig, saveConfig } from "./config.ts";
9
10
  import type { AntiloopState, LoopDetection, Runtime, TrackedToolCall } from "./types.ts";
10
11
 
11
12
  const ICONS = ["", "⚠️", "🛑", "🚨"] as const;
13
+ const LEVEL_NAMES = ["", "warning", "force", "abort"] as const;
12
14
 
13
15
  function newState(): AntiloopState {
14
16
  return {
@@ -23,22 +25,50 @@ function newState(): AntiloopState {
23
25
  };
24
26
  }
25
27
 
28
+ /** Compact pwd: ~-relative when inside $HOME, with trailing separator trimmed. */
29
+ function formatCwd(cwd: string): string {
30
+ const home = process.env.HOME;
31
+ if (home && cwd.startsWith(home)) {
32
+ const rel = cwd.slice(home.length).replace(/^[/\\]+/, "");
33
+ return rel ? `~/${rel}` : "~";
34
+ }
35
+ return cwd;
36
+ }
37
+
26
38
  export default function antiloopExtension(pi: ExtensionAPI) {
27
39
  const config = loadConfig();
28
40
  let state = newState();
29
41
 
42
+ /** TUI handle for forcing footer re-renders (set by the footer factory). */
43
+ let activeTui: { requestRender(force?: boolean): void } | undefined;
44
+
45
+ /** Status text shown in the footer: "(emoji_antiloop)(on/off)" per spec. */
46
+ function antiloopStatusText(): string {
47
+ if (!config.enabled) return "🔄 antiloop(off)";
48
+ if (state.currentLevel === 0) return "🔄 antiloop(on)";
49
+ return `${ICONS[state.currentLevel]} antiloop(on)×${state.consecutiveDetections}`;
50
+ }
51
+
30
52
  function updateStatus(ctx: ExtensionContext): void {
31
- if (!config.enabled) ctx.ui.setStatus("antiloop", undefined);
32
- else if (state.currentLevel === 0) ctx.ui.setStatus("antiloop", "🔄 antiloop");
33
- else ctx.ui.setStatus("antiloop", `${ICONS[state.currentLevel]} antiloop(${state.consecutiveDetections})`);
53
+ // Always set a status line so the footer shows on/off either way.
54
+ ctx.ui.setStatus("antiloop", antiloopStatusText());
55
+ activeTui?.requestRender();
34
56
  }
35
57
 
36
- const rt: Runtime = { config, state, pendingIntervention: null, updateStatus };
58
+ const rt: Runtime = { config, state, pendingIntervention: null, updateStatus, refreshFooter: installFooter };
37
59
  const setPending = (v: string | null) => { rt.pendingIntervention = v; };
38
60
 
61
+ /** Toggle enable/disable, persisting config and refreshing the footer. */
62
+ function toggleEnabled(ctx: ExtensionContext): void {
63
+ config.enabled = !config.enabled;
64
+ saveConfig(config);
65
+ ctx.ui.notify(`antiloop: ${config.enabled ? "ON" : "OFF"}`, "info");
66
+ updateStatus(ctx);
67
+ }
68
+
39
69
  function processDetections(
40
70
  detections: LoopDetection[],
41
- interventionMessage: (level: 1 | 2 | 3, d: LoopDetection[]) => string,
71
+ interventionMessage: (level: 2 | 3, d: LoopDetection[]) => string,
42
72
  ): void {
43
73
  if (!detections.length) {
44
74
  if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
@@ -59,12 +89,109 @@ export default function antiloopExtension(pi: ExtensionAPI) {
59
89
  else if (state.consecutiveDetections >= config.warningThreshold) next = 1;
60
90
  if (next > state.currentLevel) state.currentLevel = next;
61
91
 
62
- if (state.currentLevel > 0) {
63
- setPending(interventionMessage(state.currentLevel as 1 | 2 | 3, detections));
64
- state.inForcedBreak = state.currentLevel >= 2;
92
+ // Warning (level 1) is informational only: notify the user but DO NOT
93
+ // inject any message into the conversation. Injecting at warning level
94
+ // made the model respond to the warning, which could stall generation
95
+ // even though hard-kill turns remained. Only force (2) / abort (3) inject.
96
+ if (state.currentLevel >= 2) {
97
+ setPending(interventionMessage(state.currentLevel as 2 | 3, detections));
98
+ state.inForcedBreak = true;
99
+ } else if (state.currentLevel === 1) {
100
+ state.inForcedBreak = false;
65
101
  }
66
102
  }
67
103
 
104
+ // ------------------------------------------------------------------
105
+ // Interactive footer (TUI). Replaces the built-in footer with a line
106
+ // per spec — "🔄 antiloop(on|off)" — plus live detection info, a
107
+ // keyboard toggle (esc+a by default, configurable/off), and the
108
+ // built-in footer's useful data (pwd, branch, ctx %, model) preserved.
109
+ // ------------------------------------------------------------------
110
+ function installFooter(ctx: ExtensionContext): void {
111
+ if (!config.interactiveFooter || ctx.mode !== "tui") {
112
+ ctx.ui.setFooter(undefined);
113
+ return;
114
+ }
115
+ ctx.ui.setFooter((tui, theme, footerData) => {
116
+ activeTui = tui;
117
+
118
+ // Keyboard toggle from raw terminal input: escape followed by `a`.
119
+ // We never consume the input, so typing is unaffected — ESC alone
120
+ // passes through, and an accidental toggle is easily reversed.
121
+ let pendingEsc = false;
122
+ const shortcut = config.toggleShortcut;
123
+ const unsubInput =
124
+ shortcut === "off"
125
+ ? undefined
126
+ : ctx.ui.onTerminalInput?.((data: string) => {
127
+ if (config.toggleShortcut === "off") return undefined;
128
+ if (data === "\x1b") {
129
+ pendingEsc = true;
130
+ return undefined;
131
+ }
132
+ if (pendingEsc && data === "a") {
133
+ pendingEsc = false;
134
+ toggleEnabled(ctx);
135
+ return undefined;
136
+ }
137
+ pendingEsc = false;
138
+ return undefined;
139
+ });
140
+
141
+ return {
142
+ dispose() {
143
+ unsubInput?.();
144
+ if (activeTui === tui) activeTui = undefined;
145
+ },
146
+ invalidate() {},
147
+ render(width: number): string[] {
148
+ const lines: string[] = [];
149
+
150
+ // Line 1: the spec indicator + toggle hint.
151
+ const status = antiloopStatusText();
152
+ const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
153
+ const hint =
154
+ config.toggleShortcut !== "off"
155
+ ? theme.fg("dim", ` [${config.toggleShortcut}] toggle`)
156
+ : theme.fg("dim", " [/antiloop] toggle");
157
+ lines.push(truncateToWidth(colored + hint, width));
158
+
159
+ // Line 2 (only while detecting): level + consecutive + last reason.
160
+ if (state.currentLevel > 0) {
161
+ const last = state.detections[state.detections.length - 1];
162
+ const desc = last ? ` · ${last.description}` : "";
163
+ lines.push(
164
+ truncateToWidth(
165
+ theme.fg("warning", `${LEVEL_NAMES[state.currentLevel]} ×${state.consecutiveDetections}${desc}`),
166
+ width,
167
+ ),
168
+ );
169
+ }
170
+
171
+ // Line 3: built-in footer data preserved (dim).
172
+ let info = formatCwd(ctx.cwd);
173
+ const branch = footerData.getGitBranch();
174
+ if (branch) info += ` (${branch})`;
175
+ const usage = ctx.getContextUsage();
176
+ const cw = usage?.contextWindow ?? ctx.model?.contextWindow;
177
+ if (cw && usage && usage.percent !== null) info += ` · ctx ${Math.round(usage.percent)}%`;
178
+ if (ctx.model) info += ` · ${ctx.model.id}`;
179
+ lines.push(truncateToWidth(theme.fg("dim", info), width));
180
+
181
+ // Line 4 (only when other extensions set statuses): keep them visible.
182
+ const others = Array.from(footerData.getExtensionStatuses().entries())
183
+ .filter(([k]) => k !== "antiloop")
184
+ .map(([, v]) => v);
185
+ if (others.length) {
186
+ lines.push(truncateToWidth(theme.fg("dim", others.join(" ")), width));
187
+ }
188
+
189
+ return lines;
190
+ },
191
+ };
192
+ });
193
+ }
194
+
68
195
  pi.on("message_end", async (event) => {
69
196
  if (!config.enabled) return;
70
197
  const msg = event.message;
@@ -180,9 +307,17 @@ export default function antiloopExtension(pi: ExtensionAPI) {
180
307
  Object.assign(config, loadConfig());
181
308
  state = newState();
182
309
  rt.pendingIntervention = null;
310
+ installFooter(ctx);
183
311
  updateStatus(ctx);
184
312
  });
185
313
 
314
+ pi.on("session_shutdown", async (_e, ctx) => {
315
+ // Restore the built-in footer on shutdown (in case another extension
316
+ // installs its own footer later, or the TUI is torn down).
317
+ ctx.ui.setFooter(undefined);
318
+ activeTui = undefined;
319
+ });
320
+
186
321
  pi.registerCommand("antiloop", {
187
322
  description: "antiloop: detect & break reasoning loops",
188
323
  getArgumentCompletions: (prefix: string) => {
@@ -195,5 +330,3 @@ export default function antiloopExtension(pi: ExtensionAPI) {
195
330
  },
196
331
  });
197
332
  }
198
-
199
-
package/src/types.ts CHANGED
@@ -31,6 +31,17 @@ export interface AntiloopConfig {
31
31
  notifyOnDetection: boolean;
32
32
  maxHistoryEntries: number;
33
33
  detectionWindow: number;
34
+ /**
35
+ * Show the antiloop indicator as a custom interactive footer in TUI mode
36
+ * (replaces the built-in footer). When false, the indicator is still shown
37
+ * as a status line in the built-in footer via ctx.ui.setStatus.
38
+ */
39
+ interactiveFooter: boolean;
40
+ /**
41
+ * Key sequence that toggles antiloop from the footer (raw terminal input).
42
+ * Format: "esc+a" (escape followed by `a`) or "off" to disable.
43
+ */
44
+ toggleShortcut: string;
34
45
  }
35
46
 
36
47
  export type LoopKind = "text" | "tool" | "thinking" | "structural";
@@ -81,4 +92,6 @@ export interface Runtime {
81
92
  state: AntiloopState;
82
93
  pendingIntervention: string | null;
83
94
  updateStatus(ctx: ExtensionContext): void;
95
+ /** Re-install the interactive footer (after config changes). */
96
+ refreshFooter?(ctx: ExtensionContext): void;
84
97
  }