pi-antiloop 1.1.0 → 1.3.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
@@ -13,10 +13,11 @@
13
13
  ## Features
14
14
 
15
15
  - **Four detection strategies** — text repetition (trigram Jaccard + Levenshtein), tool-call sequences (name + near-identical arguments + same outcome — result-aware, so retries that make progress don't false-positive), thinking blocks, and structural opening-phrase patterns
16
+ - **Task-stream recognition (batch work)** — when another extension (e.g. `punched` appending lines to pi.md, or `plan` adding tasks) makes the model call the *same* tool many times with *different* content, antiloop recognizes it as N distinct tasks of one type and stays silent — no warning, no force break. A genuine loop (the *same* call repeated verbatim) is still caught
16
17
  - **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
18
  - **Configurable thresholds** — independent dials for similarity cutoff, warning/force-break/abort counts, detection window, and which strategies are on
18
19
  - **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
20
+ - **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
21
  - **Detection log** — timestamped history with similarity scores, filterable through the native pi menu
21
22
  - **Self-test** — `/antiloop test` runs built-in cases to verify the similarity engine is calibrated
22
23
  - **User input softens detection** — each new user message decays the consecutive counter so a fresh prompt can resolve the loop without manual reset
@@ -115,6 +116,9 @@ Interactive menu with current values:
115
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)
116
117
  - **Tool repeat** — `1 / 2 / 3` — prior occurrences of the same call set required before a tool loop flags
117
118
  - **Result similarity** — `0.95 / 0.8 / 0.6` — how similar captured results must be to count as the *same outcome* (veto when a repeated command starts succeeding/differing)
119
+ - **Task streams** — on/off — recognize homogeneous batch work (same extension tool, distinct content) as N tasks of one type, not a loop
120
+ - **Stream min calls** — `2 / 3 / 4 / 5` — same-tool calls required inside the window before batch recognition kicks in (default 3)
121
+ - **Stream twin threshold** — `99% / 95% / 90%` — arguments this similar count as the *same task*; a twin invalidates the stream and re-enables normal detection (default 99%)
118
122
  - **Detection window** — `5 / 10 / 15 / 20` — number of recent messages to analyze
119
123
  - **Per-strategy toggles** — text / tool / thinking detection
120
124
  - **Notifications** — show detection notifications
@@ -139,6 +143,11 @@ tool different tool → no match (exp no match) ✅
139
143
  tool empty lists → no match (exp no match) ✅
140
144
  result same outcome → match (exp match — PID noise ok) ✅
141
145
  result diff outcome → no match (exp no match — error→success is progress) ✅
146
+ batch stream detected → punched_log×3 (exp punched_log×3) ✅ ← task streams: N tasks of one type
147
+ batch no detections → silent (exp silent — 98.9% args would match without gate) ✅
148
+ loop still detected → tool (exp tool — identical repeats are NOT a stream) ✅
149
+ loop survives batch gate→ tool (exp tool — bash repeats are real) ✅
150
+ stream needs ≥3 calls → no stream (exp no stream at 2 calls) ✅
142
151
  ```
143
152
 
144
153
  ## How It Works
@@ -153,6 +162,7 @@ After every assistant `message_end` event, antiloop extracts the new content (te
153
162
  | Tool | Tool name + arguments (+ captured result) | Sequence match + near-identical args (≥ `toolSimilarityThreshold`, default 95%) *and* ≥ `minToolRepeatCount` prior recurrences. **Result veto:** if both runs captured a result and the outcomes differ, it's progress, not a loop |
154
163
  | Thinking | Internal reasoning/thinking blocks | Same as text |
155
164
  | Structural | First 10 words of each message | Opening-phrase similarity ≥ 90% across ≥ 3 messages |
165
+ | Task stream | Same tool, many calls | When a tool appears ≥ `taskStreamMinCalls` times (default 3) in the window and *no two* calls are near-identical (`taskStreamTwinThreshold`, default 99%), the tool is an active batch: N different tasks of one type (e.g. `punched_log` appends, `plan_manager` task adds). Those calls are exempt from tool-loop detection, and text/thinking/structural patterns that only involve those batch messages are suppressed too. If even one call pair is a twin (the same task repeated), the tool is *not* a stream and detection proceeds normally |
156
166
 
157
167
  Each detected pair becomes a `LoopDetection { type, similarity, messageIndices, description }` and the consecutive counter increases.
158
168
 
@@ -161,7 +171,7 @@ Each detected pair becomes a `LoopDetection { type, similarity, messageIndices,
161
171
  | Level | Trigger | Behavior |
162
172
  |-------|---------|----------|
163
173
  | 0 (no loop) | — | Silent — passes the message through |
164
- | 1 (warning) | `consecutiveDetections >= warningThreshold` | Injects a soft reminder asking the model to vary its approach |
174
+ | 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
175
  | 2 (force break) | `consecutiveDetections >= forceBreakThreshold` | Injects mandatory anti-loop instructions + appends a context message to the last assistant message |
166
176
  | 3 (abort) | `consecutiveDetections >= abortThreshold` | (Disabled by default) Surfaces an error asking the user for new instructions |
167
177
 
@@ -206,6 +216,40 @@ If the same command produced a *different* outcome, the pair is progress:
206
216
 
207
217
  Results only veto; they never trigger on their own, and calls without a
208
218
  captured result fall back to argument matching alone.
219
+
220
+ ### Task streams: N tasks of one type ≠ a loop
221
+
222
+ Extensions push homogeneous work into the model's hands. `punched` appends
223
+ lines to pi.md one at a time; `plan` adds tasks one at a time; `obsidian_*`
224
+ writes/edits notes file by file. Each call is a *different* task — "add line 1",
225
+ "add line 2", "add line 3" — of the *same type*, and the narration around it
226
+ sounds similar ("now appending the next entry…"). That is not a reasoning
227
+ loop; flagging it would interrupt legitimate multi-step work.
228
+
229
+ Antiloop recognizes this as a **task stream**: if the same tool name appears at
230
+ least `taskStreamMinCalls` times (default 3) inside the detection window and
231
+ no two calls are "twins" (arguments ≥ `taskStreamTwinThreshold` similar,
232
+ default 99% — any real content difference counts as a distinct task), the
233
+ tool is treated as an active batch:
234
+
235
+ - tool-loop detection skips messages whose calls are all stream tools;
236
+ - text / thinking / structural detections that only involve those batch
237
+ messages are suppressed (identical narration while adding N entries is
238
+ expected, not looping);
239
+ - the footer shows it: `🔄 antiloop(on) · batch: punched_log×4`.
240
+
241
+ Crucially, the exemption requires **no twins**: the moment the same call
242
+ repeats verbatim (or near-verbatim), the stream is invalid and normal loop
243
+ detection takes over — so a model stuck re-logging the *same* line still gets
244
+ caught. The check is name-agnostic: any extension tool used as a batch is
245
+ covered, no allow-list needed. If a genuine loop coexists with a batch (e.g.
246
+ the same `bash` command re-run while appending different notes), the loop is
247
+ still flagged.
248
+
249
+ Tunables: `detectTaskStreams` (master switch), `taskStreamMinCalls` (batch
250
+ size needed before recognition), `taskStreamTwinThreshold` (how similar args
251
+ must be to count as *the same task* — lower it to treat near-duplicate
252
+ entries as loops again).
209
253
  ```
210
254
 
211
255
  ### Sliding window
@@ -226,12 +270,17 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
226
270
  "toolSimilarityThreshold": 0.95,
227
271
  "minToolRepeatCount": 2,
228
272
  "resultSimilarityThreshold": 0.8,
273
+ "detectTaskStreams": true,
274
+ "taskStreamMinCalls": 3,
275
+ "taskStreamTwinThreshold": 0.99,
229
276
  "detectToolLoops": true,
230
277
  "detectThinkingLoops": true,
231
278
  "detectTextLoops": true,
232
279
  "notifyOnDetection": true,
233
280
  "maxHistoryEntries": 100,
234
- "detectionWindow": 10
281
+ "detectionWindow": 10,
282
+ "interactiveFooter": true,
283
+ "toggleShortcut": "esc+a"
235
284
  }
236
285
  ```
237
286
 
@@ -245,12 +294,17 @@ Persisted as JSON at `~/.pi/agent/antiloop.json`:
245
294
  | `toolSimilarityThreshold` | `0.95` | How close tool-call arguments must be (0.0–1.0) to count as the *same* call — see [tool loops](#how-it-works) |
246
295
  | `minToolRepeatCount` | `2` | Prior occurrences of a near-identical call set required before a tool loop is flagged (2 = same call seen 3×) |
247
296
  | `resultSimilarityThreshold` | `0.8` | Minimum similarity between captured result tails to still count as the *same outcome*; below this, a repeated command is treated as progress, not a loop |
297
+ | `detectTaskStreams` | `true` | Recognize homogeneous batch work (same tool called with distinct content — e.g. punched/plan/obsidian extensions) and stay silent; see [task streams](#task-streams-n-tasks-of-one-type--a-loop) |
298
+ | `taskStreamMinCalls` | `3` | Same-tool calls required inside the window before a task stream is recognized |
299
+ | `taskStreamTwinThreshold` | `0.99` | Arguments this similar (or identical) count as *the same task* — a twin invalidates the stream and re-enables normal loop detection |
248
300
  | `detectTextLoops` | `true` | Detect full-text repetition |
249
301
  | `detectToolLoops` | `true` | Detect tool-call sequence + argument repetition |
250
302
  | `detectThinkingLoops` | `true` | Detect repeated thinking/reasoning content |
251
303
  | `notifyOnDetection` | `true` | Show a notification on every detection |
252
304
  | `maxHistoryEntries` | `100` | Max detection history entries |
253
305
  | `detectionWindow` | `10` | Number of recent messages to analyze |
306
+ | `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) |
307
+ | `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
308
 
255
309
  ## Best Practices
256
310
 
@@ -286,8 +340,8 @@ Modular extension with zero external dependencies (only pi's bundled `@earendil-
286
340
  - **Levenshtein + trigram Jaccard** hybrid — small texts use edit distance, large texts use n-gram overlap (each is O(N) in text length)
287
341
  - **Sliding window** — only the last `detectionWindow` messages participate, capping memory at O(W × message_size)
288
342
  - **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)
343
+ - **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)
344
+ - **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
345
 
292
346
  ## License
293
347
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-antiloop",
3
- "version": "1.1.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.",
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.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "antiloop",
package/src/commands.ts CHANGED
@@ -59,9 +59,14 @@ async function showStatus(ctx: ExtensionCommandContext, rt: Runtime): Promise<vo
59
59
  ` similarity: ${(rt.config.similarityThreshold * 100).toFixed(0)}% window: ${rt.config.detectionWindow}`,
60
60
  ` tool sim: ${(rt.config.toolSimilarityThreshold * 100).toFixed(0)}% tool repeat: ${rt.config.minToolRepeatCount}+ prior`,
61
61
  ` result sim: ${(rt.config.resultSimilarityThreshold * 100).toFixed(0)}% (same cmd + diff outcome = no loop)`,
62
+ ` task streams: ${yn(rt.config.detectTaskStreams)} (min ${rt.config.taskStreamMinCalls} calls, twins ≥ ${(rt.config.taskStreamTwinThreshold * 100).toFixed(0)}%)`,
62
63
  "",
63
64
  `detectors: text ${yn(rt.config.detectTextLoops)} · tool ${yn(rt.config.detectToolLoops)} · think ${yn(rt.config.detectThinkingLoops)}`,
65
+ `footer: interactive ${yn(rt.config.interactiveFooter)} · toggle: ${rt.config.toggleShortcut}`,
64
66
  ];
67
+ if (rt.state.activeTaskStreams.length) {
68
+ lines.push("", `active batch: ${rt.state.activeTaskStreams.map((x) => `${x.tool}×${x.count}`).join(", ")}`);
69
+ }
65
70
  if (recent.length) {
66
71
  lines.push("", "recent:");
67
72
  for (const d of recent) lines.push(` [${d.type}] ${d.description} · ${formatDuration(Date.now() - d.timestamp)} ago`);
@@ -80,11 +85,16 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
80
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" },
81
86
  { value: "toolRepeat" as const, label: `tool repeat: ${c.minToolRepeatCount}+ prior`, description: "recurrences before a tool loop flags" },
82
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" },
83
91
  { value: "window" as const, label: `window: ${c.detectionWindow} msgs` },
84
92
  { value: "text" as const, label: `text detect: ${yn(c.detectTextLoops)}` },
85
93
  { value: "tool" as const, label: `tool detect: ${yn(c.detectToolLoops)}` },
86
94
  { value: "think" as const, label: `think detect: ${yn(c.detectThinkingLoops)}` },
87
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" },
88
98
  { value: "reset" as const, label: "reset state" },
89
99
  ]);
90
100
  if (!picked) return;
@@ -166,6 +176,28 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
166
176
  if (v !== undefined) { c.resultSimilarityThreshold = v; saveConfig(c); ctx.ui.notify(`result similarity: ${(v * 100).toFixed(0)}%`, "info"); }
167
177
  break;
168
178
  }
179
+ case "streams":
180
+ c.detectTaskStreams = !c.detectTaskStreams; saveConfig(c);
181
+ ctx.ui.notify(`task streams: ${yn(c.detectTaskStreams)}`, "info"); break;
182
+ case "streamMin": {
183
+ const v = await selectFrom(ctx, "stream min calls", [
184
+ { value: 2, label: "2 (sensitive)" },
185
+ { value: 3, label: "3 (default)" },
186
+ { value: 4, label: "4" },
187
+ { value: 5, label: "5 (conservative)" },
188
+ ]);
189
+ if (v !== undefined) { c.taskStreamMinCalls = v; saveConfig(c); ctx.ui.notify(`stream min calls: ${v}`, "info"); }
190
+ break;
191
+ }
192
+ case "streamTwin": {
193
+ const v = await selectFrom(ctx, "stream twin threshold (args)", [
194
+ { value: 0.99, label: "99% (default — any real difference = distinct task)" },
195
+ { value: 0.95, label: "95% (near-identical args count as same task)" },
196
+ { value: 0.9, label: "90% (aggressive loop detection)" },
197
+ ]);
198
+ if (v !== undefined) { c.taskStreamTwinThreshold = v; saveConfig(c); ctx.ui.notify(`stream twin threshold: ${(v * 100).toFixed(0)}%`, "info"); }
199
+ break;
200
+ }
169
201
  case "window": {
170
202
  const v = await selectFrom(ctx, "window", [
171
203
  { value: 5, label: "5" },
@@ -188,6 +220,20 @@ async function showConfigMenu(ctx: ExtensionCommandContext, rt: Runtime): Promis
188
220
  case "notify":
189
221
  c.notifyOnDetection = !c.notifyOnDetection; saveConfig(c);
190
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
+ }
191
237
  case "reset":
192
238
  resetState(rt.state);
193
239
  rt.pendingIntervention = null;
@@ -213,6 +259,7 @@ async function showLog(ctx: ExtensionCommandContext, rt: Runtime): Promise<void>
213
259
  export function resetState(state: AntiloopState): void {
214
260
  state.recentMessages = [];
215
261
  state.detections = [];
262
+ state.activeTaskStreams = [];
216
263
  state.currentLevel = 0;
217
264
  state.consecutiveDetections = 0;
218
265
  state.inForcedBreak = false;
package/src/config.ts CHANGED
@@ -16,12 +16,17 @@ export const DEFAULT_CONFIG: AntiloopConfig = {
16
16
  toolSimilarityThreshold: 0.95,
17
17
  minToolRepeatCount: 2,
18
18
  resultSimilarityThreshold: 0.8,
19
+ detectTaskStreams: true,
20
+ taskStreamMinCalls: 3,
21
+ taskStreamTwinThreshold: 0.99,
19
22
  detectToolLoops: true,
20
23
  detectThinkingLoops: true,
21
24
  detectTextLoops: true,
22
25
  notifyOnDetection: true,
23
26
  maxHistoryEntries: 100,
24
27
  detectionWindow: 10,
28
+ interactiveFooter: true,
29
+ toggleShortcut: "esc+a",
25
30
  };
26
31
 
27
32
  export function getConfigPath(): string {
package/src/detect.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // antiloop — similarity + detection engine. Lazy-loaded on first message_end.
2
2
 
3
- import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedToolCall } from "./types.ts";
3
+ import type { AntiloopConfig, AntiloopState, LoopDetection, TrackedMessage, TrackedToolCall } from "./types.ts";
4
4
 
5
5
  const MIN_CONTENT_LENGTH = 50;
6
6
 
@@ -108,6 +108,71 @@ function toolCallsSimilar(
108
108
  return true;
109
109
  }
110
110
 
111
+ // ---------------------------------------------------------------------------
112
+ // Task-stream recognition (batch work).
113
+ //
114
+ // Extensions like `punched` (append lines to pi.md) or `plan` (add tasks) make
115
+ // the model call the SAME tool many times in a row with DIFFERENT content:
116
+ // N distinct tasks of the same type — "adding line 1…N to a doc", "adding task
117
+ // 1…N to the plan". That is not a reasoning loop, and antiloop must not fire.
118
+ //
119
+ // A tool is an active task stream when, inside the detection window, it was
120
+ // called at least taskStreamMinCalls times and NO two calls are "twins"
121
+ // (args ≥ taskStreamTwinThreshold similar). Twins = the same task repeated;
122
+ // a stream with twins is indistinguishable from a loop and stays detected.
123
+ // The check is name-agnostic: any extension tool used as a batch is covered.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /** Near-identity of two argument payloads — unlike similarity() it works on
127
+ * short args (JSON templates like {"action":"add",...} are < 50 chars, where
128
+ * similarity() bails to 0).
129
+ */
130
+ function argsTwin(a: string, b: string, threshold: number): boolean {
131
+ const na = normalizeText(a);
132
+ const nb = normalizeText(b);
133
+ if (!na.length || !nb.length) return na === nb;
134
+ if (na === nb) return true;
135
+ if (na.length < 100 && nb.length < 100) {
136
+ const max = Math.max(na.length, nb.length);
137
+ return max > 0 && 1 - levenshtein(na, nb) / max >= threshold;
138
+ }
139
+ return similarity(a, b) >= threshold;
140
+ }
141
+
142
+ /**
143
+ * Map of tool name → call count for tools currently used as a homogeneous
144
+ * task stream in the given window. Empty map = no batch work recognized.
145
+ */
146
+ export function detectTaskStreams(
147
+ win: TrackedMessage[],
148
+ config: AntiloopConfig,
149
+ ): Map<string, number> {
150
+ const streams = new Map<string, number>();
151
+ if (!config.detectTaskStreams || win.length < config.taskStreamMinCalls) return streams;
152
+
153
+ const callsByName = new Map<string, string[]>();
154
+ for (const m of win) {
155
+ for (const tc of m.toolCalls ?? []) {
156
+ const arr = callsByName.get(tc.name) ?? [];
157
+ arr.push(tc.args);
158
+ callsByName.set(tc.name, arr);
159
+ }
160
+ }
161
+
162
+ for (const [name, argsList] of callsByName) {
163
+ if (argsList.length < config.taskStreamMinCalls) continue;
164
+ let twins = 0;
165
+ for (let i = 0; i < argsList.length; i++) {
166
+ for (let j = i + 1; j < argsList.length; j++) {
167
+ if (argsTwin(argsList[i], argsList[j], config.taskStreamTwinThreshold)) twins++;
168
+ }
169
+ }
170
+ // Every call is a distinct task → batch work, exempt from loop detection.
171
+ if (twins === 0) streams.set(name, argsList.length);
172
+ }
173
+ return streams;
174
+ }
175
+
111
176
  export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopDetection[] {
112
177
  const out: LoopDetection[] = [];
113
178
  const msgs = state.recentMessages;
@@ -116,13 +181,28 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
116
181
  const win = msgs.slice(start);
117
182
  const now = Date.now();
118
183
 
184
+ // Task-stream gate: if the window is a homogeneous batch (same extension
185
+ // tool called with DISTINCT content ≥ taskStreamMinCalls times), that tool
186
+ // is exempt from tool-loop detection, and text/thinking/structural
187
+ // detections that only involve batch messages are suppressed too — the
188
+ // model is doing N different tasks of the same type, not looping.
189
+ const streams = detectTaskStreams(win, config);
190
+ const batchAt = new Set<number>();
191
+ win.forEach((m, idx) => {
192
+ const calls = m.toolCalls;
193
+ if (calls && calls.length && calls.every((c) => (streams.get(c.name) ?? 0) >= config.taskStreamMinCalls)) {
194
+ batchAt.add(start + idx);
195
+ }
196
+ });
197
+ const allBatch = (idxs: number[]): boolean => idxs.every((i) => batchAt.has(i));
198
+
119
199
  if (config.detectTextLoops) {
120
200
  const last = win[win.length - 1];
121
201
  if (last.content.length >= MIN_CONTENT_LENGTH) {
122
202
  for (let i = 0; i < win.length - 1; i++) {
123
203
  if (win[i].content.length < MIN_CONTENT_LENGTH) continue;
124
204
  const s = similarity(last.content, win[i].content);
125
- if (s >= config.similarityThreshold) {
205
+ if (s >= config.similarityThreshold && !allBatch([start + i, msgs.length - 1])) {
126
206
  out.push({
127
207
  type: "text",
128
208
  similarity: s,
@@ -142,7 +222,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
142
222
  for (let i = 0; i < opens.length - 1; i++) {
143
223
  if (similarity(last, opens[i].o) > 0.9) n++;
144
224
  }
145
- if (n >= 2) {
225
+ if (n >= 2 && !allBatch([msgs.length - 1])) {
146
226
  out.push({
147
227
  type: "structural",
148
228
  similarity: 0.9,
@@ -158,7 +238,9 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
158
238
  if (config.detectToolLoops) {
159
239
  const last = win[win.length - 1];
160
240
  const lastCalls = last.toolCalls;
161
- if (lastCalls && lastCalls.length) {
241
+ // A message whose calls are all task-stream tools is batch work — skip
242
+ // it entirely (the stream gate already proved the calls are distinct).
243
+ if (lastCalls && lastCalls.length && !batchAt.has(msgs.length - 1)) {
162
244
  const matched: number[] = [];
163
245
  for (let i = 0; i < win.length - 1; i++) {
164
246
  const prev = win[i].toolCalls;
@@ -187,7 +269,7 @@ export function detectLoops(state: AntiloopState, config: AntiloopConfig): LoopD
187
269
  for (let i = 0; i < win.length - 1; i++) {
188
270
  if (win[i].thinking && win[i].thinking!.length > 50) {
189
271
  const s = similarity(last.thinking, win[i].thinking!);
190
- if (s >= config.similarityThreshold) {
272
+ if (s >= config.similarityThreshold && !allBatch([start + i, msgs.length - 1])) {
191
273
  out.push({
192
274
  type: "thinking",
193
275
  similarity: s,
@@ -276,5 +358,67 @@ export function runSelfTest(): string[] {
276
358
  out.push(`result same outcome → ${sameCmdSameOut ? "match" : "no match"} (exp match — PID noise ok) ${sameCmdSameOut ? "✅" : "❌"}`);
277
359
  out.push(`result diff outcome → ${sameCmdDiffOut ? "match" : "no match"} (exp no match — error→success is progress) ${!sameCmdDiffOut ? "✅" : "❌"}`);
278
360
 
361
+ // --- task streams: extension batch work is NOT a loop ---
362
+ // punched_log / plan_manager / obsidian_* style tools: the model calls the
363
+ // SAME tool N times with DIFFERENT content ("add line 1…N", "add task 1…N").
364
+ // Near-identical template args (98.9% similar here) WOULD match the tool
365
+ // detector; the task-stream gate must suppress the whole window instead.
366
+ const mk = (content: string, toolCalls?: TrackedToolCall[]): TrackedMessage =>
367
+ ({ content, toolCalls, timestamp: Date.now(), turnIndex: 0 });
368
+ const noteArgs = (x: string) =>
369
+ JSON.stringify({ type: "note", title: `task ${x}`, body: "append this line to the project memory document so context is preserved" });
370
+ const tcfg: AntiloopConfig = {
371
+ enabled: true, warningThreshold: 2, forceBreakThreshold: 3, abortThreshold: 0,
372
+ similarityThreshold: 0.75, toolSimilarityThreshold: 0.95, minToolRepeatCount: 2,
373
+ resultSimilarityThreshold: 0.8, detectToolLoops: true, detectThinkingLoops: true,
374
+ detectTextLoops: true, notifyOnDetection: true, maxHistoryEntries: 100,
375
+ detectionWindow: 10, interactiveFooter: true, toggleShortcut: "esc+a",
376
+ detectTaskStreams: true, taskStreamMinCalls: 3, taskStreamTwinThreshold: 0.99,
377
+ };
378
+ const asState = (recentMessages: TrackedMessage[]): AntiloopState =>
379
+ ({ recentMessages, detections: [], activeTaskStreams: [], currentLevel: 0,
380
+ consecutiveDetections: 0, inForcedBreak: false, totalDetections: 0,
381
+ lastUserMessageTime: 0, lastDetectedTurnIndex: -1 });
382
+ const NARR = "Now I will append the next decision entry to the project memory document so we keep the context.";
383
+
384
+ // 1) punched_log batch: 3 DIFFERENT appends (args 98.9% similar, NOT twins)
385
+ // → stream recognized, tool + text + structural all suppressed.
386
+ const batchMsgs = [
387
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
388
+ mk(NARR, [{ name: "punched_log", args: noteArgs("2") }]),
389
+ mk(NARR, [{ name: "punched_log", args: noteArgs("3") }]),
390
+ ];
391
+ const batchWin = batchMsgs.slice(-tcfg.detectionWindow);
392
+ const batchStreams = detectTaskStreams(batchWin, tcfg);
393
+ const batchDet = detectLoops(asState(batchMsgs), tcfg);
394
+ out.push(`batch stream detected → ${batchStreams.get("punched_log") === 3 ? `punched_log×${batchStreams.get("punched_log")}` : "no"} (exp punched_log×3) ${batchStreams.get("punched_log") === 3 ? "✅" : "❌"}`);
395
+ out.push(`batch no detections → ${batchDet.length === 0 ? "silent" : `${batchDet.map((d) => d.type).join(",")}`} (exp silent — 98.9% args would match without gate) ${batchDet.length === 0 ? "✅" : "❌"}`);
396
+
397
+ // 2) genuine loop: SAME call repeated verbatim → twins → stream inactive,
398
+ // tool detection must still fire.
399
+ const loopMsgs = [
400
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
401
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
402
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }]),
403
+ ];
404
+ const loopDet = detectLoops(asState(loopMsgs), tcfg);
405
+ out.push(`loop still detected → ${loopDet.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — identical repeats are NOT a stream) ${loopDet.some((d) => d.type === "tool") ? "✅" : "❌"}`);
406
+
407
+ // 3) coexistence: batch tool + a genuinely repeated bash command in the
408
+ // same messages → the bash loop must still be flagged.
409
+ const loopCmd = "cd /tmp && sleep 1 && echo retrying the same build step again and again forever";
410
+ const mixedMsgs = [
411
+ mk(NARR, [{ name: "punched_log", args: noteArgs("1") }, { name: "bash", args: loopCmd }]),
412
+ mk(NARR, [{ name: "punched_log", args: noteArgs("2") }, { name: "bash", args: loopCmd }]),
413
+ mk(NARR, [{ name: "punched_log", args: noteArgs("3") }, { name: "bash", args: loopCmd }]),
414
+ ];
415
+ const mixedDet = detectLoops(asState(mixedMsgs), tcfg);
416
+ out.push(`loop survives batch gate→ ${mixedDet.some((d) => d.type === "tool") ? "tool" : "no"} (exp tool — bash repeats are real) ${mixedDet.some((d) => d.type === "tool") ? "✅" : "❌"}`);
417
+
418
+ // 4) below min calls: 2 distinct appends → no stream (could be coincidence).
419
+ const twoMsgs = [mk("a", [{ name: "punched_log", args: noteArgs("1") }]), mk("b", [{ name: "punched_log", args: noteArgs("2") }])];
420
+ const twoStreams = detectTaskStreams(twoMsgs, tcfg);
421
+ out.push(`stream needs ≥3 calls → ${twoStreams.size === 0 ? "no stream" : "stream"} (exp no stream at 2 calls) ${twoStreams.size === 0 ? "✅" : "❌"}`);
422
+
279
423
  return out;
280
424
  }
package/src/index.ts CHANGED
@@ -1,19 +1,22 @@
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 {
15
17
  recentMessages: [],
16
18
  detections: [],
19
+ activeTaskStreams: [],
17
20
  currentLevel: 0,
18
21
  consecutiveDetections: 0,
19
22
  inForcedBreak: false,
@@ -23,22 +26,58 @@ function newState(): AntiloopState {
23
26
  };
24
27
  }
25
28
 
29
+ /** Compact pwd: ~-relative when inside $HOME, with trailing separator trimmed. */
30
+ function formatCwd(cwd: string): string {
31
+ const home = process.env.HOME;
32
+ if (home && cwd.startsWith(home)) {
33
+ const rel = cwd.slice(home.length).replace(/^[/\\]+/, "");
34
+ return rel ? `~/${rel}` : "~";
35
+ }
36
+ return cwd;
37
+ }
38
+
26
39
  export default function antiloopExtension(pi: ExtensionAPI) {
27
40
  const config = loadConfig();
28
41
  let state = newState();
29
42
 
43
+ /** TUI handle for forcing footer re-renders (set by the footer factory). */
44
+ let activeTui: { requestRender(force?: boolean): void } | undefined;
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. */
48
+ 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;
59
+ }
60
+
30
61
  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})`);
62
+ // Always set a status line so the footer shows on/off either way.
63
+ ctx.ui.setStatus("antiloop", antiloopStatusText());
64
+ activeTui?.requestRender();
34
65
  }
35
66
 
36
- const rt: Runtime = { config, state, pendingIntervention: null, updateStatus };
67
+ const rt: Runtime = { config, state, pendingIntervention: null, updateStatus, refreshFooter: installFooter };
37
68
  const setPending = (v: string | null) => { rt.pendingIntervention = v; };
38
69
 
70
+ /** Toggle enable/disable, persisting config and refreshing the footer. */
71
+ function toggleEnabled(ctx: ExtensionContext): void {
72
+ config.enabled = !config.enabled;
73
+ saveConfig(config);
74
+ ctx.ui.notify(`antiloop: ${config.enabled ? "ON" : "OFF"}`, "info");
75
+ updateStatus(ctx);
76
+ }
77
+
39
78
  function processDetections(
40
79
  detections: LoopDetection[],
41
- interventionMessage: (level: 1 | 2 | 3, d: LoopDetection[]) => string,
80
+ interventionMessage: (level: 2 | 3, d: LoopDetection[]) => string,
42
81
  ): void {
43
82
  if (!detections.length) {
44
83
  if (state.consecutiveDetections > 0) state.consecutiveDetections = Math.max(0, state.consecutiveDetections - 1);
@@ -59,12 +98,109 @@ export default function antiloopExtension(pi: ExtensionAPI) {
59
98
  else if (state.consecutiveDetections >= config.warningThreshold) next = 1;
60
99
  if (next > state.currentLevel) state.currentLevel = next;
61
100
 
62
- if (state.currentLevel > 0) {
63
- setPending(interventionMessage(state.currentLevel as 1 | 2 | 3, detections));
64
- state.inForcedBreak = state.currentLevel >= 2;
101
+ // Warning (level 1) is informational only: notify the user but DO NOT
102
+ // inject any message into the conversation. Injecting at warning level
103
+ // made the model respond to the warning, which could stall generation
104
+ // even though hard-kill turns remained. Only force (2) / abort (3) inject.
105
+ if (state.currentLevel >= 2) {
106
+ setPending(interventionMessage(state.currentLevel as 2 | 3, detections));
107
+ state.inForcedBreak = true;
108
+ } else if (state.currentLevel === 1) {
109
+ state.inForcedBreak = false;
65
110
  }
66
111
  }
67
112
 
113
+ // ------------------------------------------------------------------
114
+ // Interactive footer (TUI). Replaces the built-in footer with a line
115
+ // per spec — "🔄 antiloop(on|off)" — plus live detection info, a
116
+ // keyboard toggle (esc+a by default, configurable/off), and the
117
+ // built-in footer's useful data (pwd, branch, ctx %, model) preserved.
118
+ // ------------------------------------------------------------------
119
+ function installFooter(ctx: ExtensionContext): void {
120
+ if (!config.interactiveFooter || ctx.mode !== "tui") {
121
+ ctx.ui.setFooter(undefined);
122
+ return;
123
+ }
124
+ ctx.ui.setFooter((tui, theme, footerData) => {
125
+ activeTui = tui;
126
+
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
+ let pendingEsc = false;
131
+ const shortcut = config.toggleShortcut;
132
+ const unsubInput =
133
+ shortcut === "off"
134
+ ? undefined
135
+ : ctx.ui.onTerminalInput?.((data: string) => {
136
+ if (config.toggleShortcut === "off") return undefined;
137
+ if (data === "\x1b") {
138
+ pendingEsc = true;
139
+ return undefined;
140
+ }
141
+ if (pendingEsc && data === "a") {
142
+ pendingEsc = false;
143
+ toggleEnabled(ctx);
144
+ return undefined;
145
+ }
146
+ pendingEsc = false;
147
+ return undefined;
148
+ });
149
+
150
+ return {
151
+ dispose() {
152
+ unsubInput?.();
153
+ if (activeTui === tui) activeTui = undefined;
154
+ },
155
+ invalidate() {},
156
+ render(width: number): string[] {
157
+ const lines: string[] = [];
158
+
159
+ // Line 1: the spec indicator + toggle hint.
160
+ const status = antiloopStatusText();
161
+ const colored = config.enabled ? theme.fg("accent", status) : theme.fg("dim", status);
162
+ const hint =
163
+ config.toggleShortcut !== "off"
164
+ ? theme.fg("dim", ` [${config.toggleShortcut}] toggle`)
165
+ : theme.fg("dim", " [/antiloop] toggle");
166
+ lines.push(truncateToWidth(colored + hint, width));
167
+
168
+ // Line 2 (only while detecting): level + consecutive + last reason.
169
+ if (state.currentLevel > 0) {
170
+ const last = state.detections[state.detections.length - 1];
171
+ const desc = last ? ` · ${last.description}` : "";
172
+ lines.push(
173
+ truncateToWidth(
174
+ theme.fg("warning", `${LEVEL_NAMES[state.currentLevel]} ×${state.consecutiveDetections}${desc}`),
175
+ width,
176
+ ),
177
+ );
178
+ }
179
+
180
+ // Line 3: built-in footer data preserved (dim).
181
+ let info = formatCwd(ctx.cwd);
182
+ const branch = footerData.getGitBranch();
183
+ if (branch) info += ` (${branch})`;
184
+ const usage = ctx.getContextUsage();
185
+ const cw = usage?.contextWindow ?? ctx.model?.contextWindow;
186
+ if (cw && usage && usage.percent !== null) info += ` · ctx ${Math.round(usage.percent)}%`;
187
+ if (ctx.model) info += ` · ${ctx.model.id}`;
188
+ lines.push(truncateToWidth(theme.fg("dim", info), width));
189
+
190
+ // Line 4 (only when other extensions set statuses): keep them visible.
191
+ const others = Array.from(footerData.getExtensionStatuses().entries())
192
+ .filter(([k]) => k !== "antiloop")
193
+ .map(([, v]) => v);
194
+ if (others.length) {
195
+ lines.push(truncateToWidth(theme.fg("dim", others.join(" ")), width));
196
+ }
197
+
198
+ return lines;
199
+ },
200
+ };
201
+ });
202
+ }
203
+
68
204
  pi.on("message_end", async (event) => {
69
205
  if (!config.enabled) return;
70
206
  const msg = event.message;
@@ -139,7 +275,7 @@ export default function antiloopExtension(pi: ExtensionAPI) {
139
275
 
140
276
  pi.on("turn_end", async (event, ctx) => {
141
277
  if (!config.enabled) return;
142
- const { detectLoops, interventionMessage, resultFingerprint } = await import("./detect.ts");
278
+ const { detectLoops, detectTaskStreams, interventionMessage, resultFingerprint } = await import("./detect.ts");
143
279
 
144
280
  const last = state.recentMessages[state.recentMessages.length - 1];
145
281
  // A turn whose assistant message wasn't tracked (short text, no tools)
@@ -165,6 +301,14 @@ export default function antiloopExtension(pi: ExtensionAPI) {
165
301
  }
166
302
  }
167
303
 
304
+ // Recomputed batch streams (punched_log / plan_manager / … used as a
305
+ // homogeneous task stream) — shown in the footer so the user sees why
306
+ // antiloop stays quiet while the model performs N tasks of one type.
307
+ const winStart = Math.max(0, state.recentMessages.length - config.detectionWindow);
308
+ state.activeTaskStreams = Array.from(
309
+ detectTaskStreams(state.recentMessages.slice(winStart), config).entries(),
310
+ ).map(([tool, count]) => ({ tool, count }));
311
+
168
312
  const detections = detectLoops(state, config);
169
313
  processDetections(detections, interventionMessage);
170
314
 
@@ -180,9 +324,17 @@ export default function antiloopExtension(pi: ExtensionAPI) {
180
324
  Object.assign(config, loadConfig());
181
325
  state = newState();
182
326
  rt.pendingIntervention = null;
327
+ installFooter(ctx);
183
328
  updateStatus(ctx);
184
329
  });
185
330
 
331
+ pi.on("session_shutdown", async (_e, ctx) => {
332
+ // Restore the built-in footer on shutdown (in case another extension
333
+ // installs its own footer later, or the TUI is torn down).
334
+ ctx.ui.setFooter(undefined);
335
+ activeTui = undefined;
336
+ });
337
+
186
338
  pi.registerCommand("antiloop", {
187
339
  description: "antiloop: detect & break reasoning loops",
188
340
  getArgumentCompletions: (prefix: string) => {
@@ -195,5 +347,3 @@ export default function antiloopExtension(pi: ExtensionAPI) {
195
347
  },
196
348
  });
197
349
  }
198
-
199
-
package/src/types.ts CHANGED
@@ -25,12 +25,36 @@ export interface AntiloopConfig {
25
25
  * loop. Same command + different outcome = progress, not a loop.
26
26
  */
27
27
  resultSimilarityThreshold: number;
28
+ /**
29
+ * Task-stream recognition (batch work). Extensions like punched (append
30
+ * lines to pi.md) or plan (add tasks) make the model call the SAME tool
31
+ * many times with DIFFERENT content — N distinct tasks of the same type,
32
+ * not a loop. When the same tool appears at least taskStreamMinCalls times
33
+ * in the window and no two calls are "twins" (args ≥ taskStreamTwinThreshold
34
+ * similar), antiloop treats that tool as an active task stream and does not
35
+ * flag repetitions of it, nor text/thinking/structural patterns that only
36
+ * involve those batch messages.
37
+ */
38
+ detectTaskStreams: boolean;
39
+ taskStreamMinCalls: number;
40
+ taskStreamTwinThreshold: number;
28
41
  detectToolLoops: boolean;
29
42
  detectThinkingLoops: boolean;
30
43
  detectTextLoops: boolean;
31
44
  notifyOnDetection: boolean;
32
45
  maxHistoryEntries: number;
33
46
  detectionWindow: number;
47
+ /**
48
+ * Show the antiloop indicator as a custom interactive footer in TUI mode
49
+ * (replaces the built-in footer). When false, the indicator is still shown
50
+ * as a status line in the built-in footer via ctx.ui.setStatus.
51
+ */
52
+ interactiveFooter: boolean;
53
+ /**
54
+ * Key sequence that toggles antiloop from the footer (raw terminal input).
55
+ * Format: "esc+a" (escape followed by `a`) or "off" to disable.
56
+ */
57
+ toggleShortcut: string;
34
58
  }
35
59
 
36
60
  export type LoopKind = "text" | "tool" | "thinking" | "structural";
@@ -64,9 +88,18 @@ export interface TrackedMessage {
64
88
  turnIndex: number;
65
89
  }
66
90
 
91
+ export interface TaskStreamInfo {
92
+ tool: string;
93
+ count: number;
94
+ }
95
+
67
96
  export interface AntiloopState {
68
97
  recentMessages: TrackedMessage[];
69
98
  detections: LoopDetection[];
99
+ /** Tools currently being used as a homogeneous batch (N tasks, same type).
100
+ * Recomputed at turn_end; shown in the footer/status so a quiet antiloop is
101
+ * explainable. */
102
+ activeTaskStreams: TaskStreamInfo[];
70
103
  currentLevel: 0 | 1 | 2 | 3;
71
104
  consecutiveDetections: number;
72
105
  inForcedBreak: boolean;
@@ -81,4 +114,6 @@ export interface Runtime {
81
114
  state: AntiloopState;
82
115
  pendingIntervention: string | null;
83
116
  updateStatus(ctx: ExtensionContext): void;
117
+ /** Re-install the interactive footer (after config changes). */
118
+ refreshFooter?(ctx: ExtensionContext): void;
84
119
  }