pi-loop-mode 2.5.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.
@@ -0,0 +1,1442 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { createHash } from "node:crypto";
3
+ import { appendFileSync, readFileSync } from "node:fs";
4
+
5
+ const STATE_ENTRY_TYPE = "loop-state";
6
+ const MESSAGE_TYPE = "loop";
7
+ const MAX_STORED_TEXT = 280;
8
+ const BASE_BACKOFF_SECONDS = 5;
9
+ const MAX_BACKOFF_SECONDS = 300;
10
+ const NO_PROGRESS_WINDOW = 8;
11
+ const AUTO_RESUME_DELAY_MS = 3_000;
12
+ /** Near-duplicate threshold for consecutive assistant responses (Jaccard on word trigrams). */
13
+ const SIMILARITY_THRESHOLD = 0.8;
14
+ /** Same fingerprint seen this many times in the recent window counts as stuck. */
15
+ const REPEAT_WINDOW_COUNT = 3;
16
+ /** Turns without any tool call before a stuck intervention fires. */
17
+ const MAX_TOOLLESS_TURNS = 3;
18
+ /** Consecutive stuck interventions before the hard-reset escalation kicks in. */
19
+ const HARD_RESET_AFTER = 3;
20
+ /** A sentence repeated this often inside ONE response = degenerate generation. */
21
+ const DEGENERATE_REPEATS = 4;
22
+ /** During streaming, abort once the repeated-sentence count reaches this. */
23
+ const DEGENERATE_STREAM_REPEATS = 6;
24
+ /** Re-run the degeneration check every N new streamed characters. */
25
+ const DEGENERATE_CHECK_INTERVAL = 500;
26
+ /** Consecutive stuck interventions before a rescue-model turn is triggered (if configured). */
27
+ const RESCUE_AFTER = 3;
28
+ /** Consecutive stuck interventions before the context is compacted to break fixation. */
29
+ const COMPACT_AFTER = 5;
30
+ /** Iterations that anti-repetition sampling penalties stay active after a stuck intervention. */
31
+ const PENALTY_TURNS = 3;
32
+ /** JSONL log with one entry per loop iteration/event (cwd). */
33
+ const LOG_FILE = ".pi-loop-log.jsonl";
34
+
35
+ type TurnKind =
36
+ | "start"
37
+ | "continue"
38
+ | "stuck"
39
+ | "recover"
40
+ | "improve"
41
+ | "unblock"
42
+ | "audit"
43
+ | "resume"
44
+ | "regression"
45
+ | "check_failed"
46
+ | "rescue";
47
+
48
+ interface ToolSnapshot {
49
+ tool: string;
50
+ fingerprint: string;
51
+ snippet: string;
52
+ isError: boolean;
53
+ time: number;
54
+ }
55
+
56
+ interface LoopState {
57
+ active: boolean;
58
+ description: string;
59
+ completionCriteria: string;
60
+ startTime: number;
61
+ iterationCount: number;
62
+ /** 0 = unlimited (default). */
63
+ maxIterations: number;
64
+ /** If true, LOOP_DONE stops the loop. Default: endless improvement mode. */
65
+ untilDone: boolean;
66
+ /** Delay between normal iterations, in seconds. */
67
+ delaySeconds: number;
68
+ /** Objective goal function: shell command, exit 0 = criteria met, optional "SCORE: n" output. */
69
+ checkCommand: string;
70
+ checkTimeoutSeconds: number;
71
+ lastCheckPassed?: boolean;
72
+ lastCheckScore?: number;
73
+ bestCheckScore?: number;
74
+ bestScoreIteration: number;
75
+ checkFailStreak: number;
76
+ lastCheckOutput: string;
77
+ /** Spec file written by /loop prepare; referenced in loop prompts. */
78
+ goalFile: string;
79
+ /** Model spec ("provider/id" or id) the loop should run with; "" = current model. */
80
+ loopModel: string;
81
+ /** Stronger model called in for a single turn after repeated stuck interventions; "" = disabled. */
82
+ rescueModel: string;
83
+ /** True while a rescue-model turn is in flight. */
84
+ rescueActive: boolean;
85
+ /** Iterations during which anti-repetition sampling penalties are applied. */
86
+ penaltyTurnsRemaining: number;
87
+ /** Iteration of the last automatic compaction (stuck escalation). */
88
+ lastCompactIteration: number;
89
+ /** Timestamp of successful /loop prepare (GOAL_READY marker); 0 = not prepared. */
90
+ preparedAt: number;
91
+ lastAssistantFingerprints: string[];
92
+ lastAssistantSnippets: string[];
93
+ /** Longer recent assistant texts used for near-duplicate similarity detection. */
94
+ lastAssistantTexts: string[];
95
+ recentToolResults: ToolSnapshot[];
96
+ /** Consecutive turns in which the assistant made zero tool calls (narration only). */
97
+ turnsWithoutTools: number;
98
+ /** Tool calls observed since the current turn started; reset every agent_end. */
99
+ toolCallsThisTurn: number;
100
+ consecutiveStuckCount: number;
101
+ interventionCount: number;
102
+ consecutiveErrorCount: number;
103
+ totalErrorCount: number;
104
+ doneSignalCount: number;
105
+ blockedSignalCount: number;
106
+ lastStateChangeIteration: number;
107
+ status: "running" | "stuck" | "retrying" | "paused" | "completed" | "stopped" | "preparing";
108
+ lastNotice: string;
109
+ }
110
+
111
+ function defaultState(): LoopState {
112
+ return {
113
+ active: false,
114
+ description: "",
115
+ completionCriteria: "",
116
+ startTime: 0,
117
+ iterationCount: 0,
118
+ maxIterations: 0,
119
+ untilDone: false,
120
+ delaySeconds: 0,
121
+ checkCommand: "",
122
+ checkTimeoutSeconds: 120,
123
+ bestScoreIteration: 0,
124
+ checkFailStreak: 0,
125
+ lastCheckOutput: "",
126
+ goalFile: "GOAL.md",
127
+ loopModel: "",
128
+ rescueModel: "",
129
+ rescueActive: false,
130
+ penaltyTurnsRemaining: 0,
131
+ lastCompactIteration: 0,
132
+ preparedAt: 0,
133
+ lastAssistantFingerprints: [],
134
+ lastAssistantSnippets: [],
135
+ lastAssistantTexts: [],
136
+ recentToolResults: [],
137
+ turnsWithoutTools: 0,
138
+ toolCallsThisTurn: 0,
139
+ consecutiveStuckCount: 0,
140
+ interventionCount: 0,
141
+ consecutiveErrorCount: 0,
142
+ totalErrorCount: 0,
143
+ doneSignalCount: 0,
144
+ blockedSignalCount: 0,
145
+ lastStateChangeIteration: 0,
146
+ status: "stopped",
147
+ lastNotice: "",
148
+ };
149
+ }
150
+
151
+ let state: LoopState = defaultState();
152
+ let pendingTimer: ReturnType<typeof setTimeout> | undefined;
153
+ /** Set when we abort a streaming response due to degenerate repetition; consumed in agent_end. */
154
+ let degenerateAbortPending = false;
155
+ /** Stream position of the last degeneration check (throttle). */
156
+ let lastDegenerateCheckLength = 0;
157
+
158
+ function clearPendingTimer(): void {
159
+ if (pendingTimer) {
160
+ clearTimeout(pendingTimer);
161
+ pendingTimer = undefined;
162
+ }
163
+ }
164
+
165
+ function normalizeText(text: string): string {
166
+ return text
167
+ .replace(/\x1b\[[0-9;]*m/g, "")
168
+ .replace(/\s+/g, " ")
169
+ .trim()
170
+ .toLowerCase();
171
+ }
172
+
173
+ function fingerprint(text: string): string {
174
+ return createHash("sha256").update(normalizeText(text).slice(0, 4_000)).digest("hex").slice(0, 16);
175
+ }
176
+
177
+ /** Normalizes text for similarity: lowercased, whitespace collapsed, digits masked (iteration counters, file numbers …). */
178
+ function stripVolatile(text: string): string {
179
+ return normalizeText(text).replace(/\d+/g, "#");
180
+ }
181
+
182
+ function wordShingles(text: string, n = 3): Set<string> {
183
+ const words = stripVolatile(text).split(" ").filter(Boolean);
184
+ const set = new Set<string>();
185
+ if (words.length < n) {
186
+ if (words.length > 0) set.add(words.join(" "));
187
+ return set;
188
+ }
189
+ for (let i = 0; i <= words.length - n; i++) set.add(words.slice(i, i + n).join(" "));
190
+ return set;
191
+ }
192
+
193
+ /** Jaccard similarity of word trigrams; 1 = identical, 0 = disjoint. */
194
+ function textSimilarity(a: string, b: string): number {
195
+ const setA = wordShingles(a);
196
+ const setB = wordShingles(b);
197
+ if (setA.size === 0 || setB.size === 0) return 0;
198
+ let intersection = 0;
199
+ for (const shingle of setA) if (setB.has(shingle)) intersection++;
200
+ return intersection / (setA.size + setB.size - intersection);
201
+ }
202
+
203
+ interface DegenerateInfo {
204
+ repeats: number;
205
+ sentence: string;
206
+ }
207
+
208
+ /** Detects one sentence dominating a response ("Let me continue with improvements. …" × 40). */
209
+ function degenerateRepetition(text: string, minRepeats: number): DegenerateInfo | undefined {
210
+ const normalized = stripVolatile(text);
211
+ if (normalized.length < 150) return undefined;
212
+ const parts = normalized
213
+ .split(/(?<=[.!?])\s+|\n+/)
214
+ .map((part) => part.trim())
215
+ .filter((part) => part.length >= 15);
216
+ if (parts.length < minRepeats) return undefined;
217
+ const counts = new Map<string, number>();
218
+ for (const part of parts) counts.set(part, (counts.get(part) ?? 0) + 1);
219
+ let sentence = "";
220
+ let repeats = 0;
221
+ for (const [part, count] of counts) {
222
+ if (count > repeats) {
223
+ sentence = part;
224
+ repeats = count;
225
+ }
226
+ }
227
+ if (repeats >= minRepeats && repeats / parts.length >= 0.5) return { repeats, sentence };
228
+ return undefined;
229
+ }
230
+
231
+ /** Truncates a degenerate response after the first repetition so the garbage never reinforces itself in context. */
232
+ function sanitizeDegenerateText(text: string): string | undefined {
233
+ const info = degenerateRepetition(text, DEGENERATE_REPEATS);
234
+ if (!info) return undefined;
235
+ // Cut roughly after the first ~2 occurrences of the repeated content.
236
+ const keepLength = Math.max(200, Math.min(text.length, Math.ceil((text.length / info.repeats) * 2)));
237
+ return (
238
+ `${text.slice(0, keepLength).trimEnd()}\n\n` +
239
+ `[loop: degenerate repetition truncated — the same sentence repeated ${info.repeats}×. Do not continue this pattern.]`
240
+ );
241
+ }
242
+
243
+ /** Replaces the text content of a message, preserving non-text parts (e.g. thinking). */
244
+ function replaceMessageText(message: { content?: unknown }, newText: string): unknown {
245
+ const content = message.content;
246
+ if (typeof content === "string") return { ...message, content: newText };
247
+ if (Array.isArray(content)) {
248
+ let replaced = false;
249
+ const parts = content
250
+ .map((part) => {
251
+ if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
252
+ if (replaced) return undefined;
253
+ replaced = true;
254
+ return { ...(part as object), text: newText };
255
+ }
256
+ return part;
257
+ })
258
+ .filter((part) => part !== undefined);
259
+ if (!replaced) parts.push({ type: "text", text: newText });
260
+ return { ...message, content: parts };
261
+ }
262
+ return { ...message, content: newText };
263
+ }
264
+
265
+ /** Openings of recent responses; injected as banned phrases during hard-reset escalation. */
266
+ function bannedOpenings(): string {
267
+ const openings = new Set<string>();
268
+ for (const snip of state.lastAssistantSnippets.slice(-3)) {
269
+ const words = snip.split(/\s+/).slice(0, 6).join(" ");
270
+ if (words) openings.add(`"${words}…"`);
271
+ }
272
+ return [...openings].join(", ") || "-";
273
+ }
274
+
275
+ function snippet(text: string, limit = MAX_STORED_TEXT): string {
276
+ const normalized = text.replace(/\s+/g, " ").trim();
277
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, limit)}…`;
278
+ }
279
+
280
+ function contentToText(content: unknown): string {
281
+ if (typeof content === "string") return content;
282
+ if (Array.isArray(content)) {
283
+ return content
284
+ .map((part) => {
285
+ if (typeof part === "string") return part;
286
+ if (part && typeof part === "object" && "text" in part && typeof (part as { text?: unknown }).text === "string") {
287
+ return (part as { text: string }).text;
288
+ }
289
+ return "";
290
+ })
291
+ .filter(Boolean)
292
+ .join("\n");
293
+ }
294
+ if (content && typeof content === "object" && "text" in content && typeof (content as { text?: unknown }).text === "string") {
295
+ return (content as { text: string }).text;
296
+ }
297
+ return "";
298
+ }
299
+
300
+ function messageToText(message: unknown): string {
301
+ if (!message || typeof message !== "object") return "";
302
+ return contentToText((message as { content?: unknown }).content);
303
+ }
304
+
305
+ function pushLimited<T>(items: T[], item: T, max: number): void {
306
+ items.push(item);
307
+ while (items.length > max) items.shift();
308
+ }
309
+
310
+ function hasStateChange(toolName: string, text: string, isError: boolean): boolean {
311
+ if (isError) return false;
312
+ if (["write", "edit"].includes(toolName)) return true;
313
+ return /\b(written|edited|changed|updated|created|deleted|renamed|committed|fixed|successfully|passed|installed)\b/i.test(text);
314
+ }
315
+
316
+ function recordToolResult(toolName: string, text: string, isError: boolean): void {
317
+ pushLimited(
318
+ state.recentToolResults,
319
+ {
320
+ tool: toolName,
321
+ fingerprint: fingerprint(text),
322
+ snippet: snippet(text),
323
+ isError,
324
+ time: Date.now(),
325
+ },
326
+ 10,
327
+ );
328
+
329
+ if (hasStateChange(toolName, text, isError)) {
330
+ state.lastStateChangeIteration = state.iterationCount + 1;
331
+ }
332
+ }
333
+
334
+ function restoreState(ctx: ExtensionContext): void {
335
+ const entries = ctx.sessionManager.getEntries();
336
+ const restored = [...entries]
337
+ .reverse()
338
+ .find((entry) => entry.type === "custom" && (entry as { customType?: string }).customType === STATE_ENTRY_TYPE) as
339
+ | { data?: Partial<LoopState> }
340
+ | undefined;
341
+
342
+ state = { ...defaultState(), ...(restored?.data ?? {}) };
343
+ }
344
+
345
+ function persistState(pi: ExtensionAPI): void {
346
+ pi.appendEntry(STATE_ENTRY_TYPE, {
347
+ ...state,
348
+ lastAssistantFingerprints: state.lastAssistantFingerprints.slice(-8),
349
+ lastAssistantSnippets: state.lastAssistantSnippets.slice(-5),
350
+ lastAssistantTexts: state.lastAssistantTexts.slice(-3),
351
+ recentToolResults: state.recentToolResults.slice(-10),
352
+ });
353
+ }
354
+
355
+ interface StartArgs {
356
+ description: string;
357
+ criteria: string;
358
+ maxIterations: number;
359
+ untilDone: boolean;
360
+ delaySeconds: number;
361
+ checkCommand: string;
362
+ checkTimeoutSeconds: number;
363
+ model: string;
364
+ rescueModel: string;
365
+ goalFile: string;
366
+ }
367
+
368
+ function extractCheckCommand(text: string): { rest: string; checkCommand: string } {
369
+ const match = text.match(/--check(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/);
370
+ if (!match || match.index === undefined) return { rest: text, checkCommand: "" };
371
+ const checkCommand = (match[1] ?? match[2] ?? match[3] ?? "").trim();
372
+ const rest = `${text.slice(0, match.index)} ${text.slice(match.index + match[0].length)}`.trim();
373
+ return { rest, checkCommand };
374
+ }
375
+
376
+ function parseStartArgs(args: string): StartArgs {
377
+ const { rest, checkCommand } = extractCheckCommand(args.trim());
378
+ const tokens = rest.split(/\s+/);
379
+ let maxIterations = 0;
380
+ let untilDone = false;
381
+ let delaySeconds = 0;
382
+ let checkTimeoutSeconds = 120;
383
+ let model = "";
384
+ let rescueModel = "";
385
+ let goalFile = "";
386
+ const kept: string[] = [];
387
+
388
+ for (let i = 0; i < tokens.length; i++) {
389
+ const token = tokens[i];
390
+ if (token === "--max" && tokens[i + 1]) {
391
+ maxIterations = Math.max(0, Number.parseInt(tokens[++i], 10) || 0);
392
+ } else if (token.startsWith("--max=")) {
393
+ maxIterations = Math.max(0, Number.parseInt(token.slice("--max=".length), 10) || 0);
394
+ } else if (token === "--delay" && tokens[i + 1]) {
395
+ delaySeconds = Math.max(0, Number.parseInt(tokens[++i], 10) || 0);
396
+ } else if (token.startsWith("--delay=")) {
397
+ delaySeconds = Math.max(0, Number.parseInt(token.slice("--delay=".length), 10) || 0);
398
+ } else if (token === "--rescue-model" && tokens[i + 1]) {
399
+ rescueModel = tokens[++i];
400
+ } else if (token.startsWith("--rescue-model=")) {
401
+ rescueModel = token.slice("--rescue-model=".length);
402
+ } else if (token === "--model" && tokens[i + 1]) {
403
+ model = tokens[++i];
404
+ } else if (token.startsWith("--model=")) {
405
+ model = token.slice("--model=".length);
406
+ } else if ((token === "--file" || token === "--goal-file") && tokens[i + 1]) {
407
+ goalFile = tokens[++i];
408
+ } else if (token.startsWith("--file=")) {
409
+ goalFile = token.slice("--file=".length);
410
+ } else if (token === "--check-timeout" && tokens[i + 1]) {
411
+ checkTimeoutSeconds = Math.max(1, Number.parseInt(tokens[++i], 10) || 120);
412
+ } else if (token.startsWith("--check-timeout=")) {
413
+ checkTimeoutSeconds = Math.max(1, Number.parseInt(token.slice("--check-timeout=".length), 10) || 120);
414
+ } else if (token === "--until-done") {
415
+ untilDone = true;
416
+ } else {
417
+ kept.push(token);
418
+ }
419
+ }
420
+
421
+ const text = kept.join(" ").trim();
422
+ const match = text.match(/^(.*?)(?:\bDone when\b\s*:?\s*)(.*)$/i);
423
+ const description = (match?.[1] ?? text).trim().replace(/[.\s]+$/, "");
424
+ const criteria = (match?.[2] ?? "").trim();
425
+ return { description, criteria, maxIterations, untilDone, delaySeconds, checkCommand, checkTimeoutSeconds, model, rescueModel, goalFile };
426
+ }
427
+
428
+ function resolveModel(ctx: ExtensionContext, spec: string) {
429
+ const slash = spec.indexOf("/");
430
+ if (slash > 0) {
431
+ const found = ctx.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1));
432
+ if (found) return found;
433
+ }
434
+ const all = ctx.modelRegistry.getAll();
435
+ const lower = spec.toLowerCase();
436
+ return (
437
+ all.find((m) => m.id.toLowerCase() === lower) ??
438
+ all.find((m) => `${m.provider}/${m.id}`.toLowerCase() === lower) ??
439
+ all.find((m) => m.id.toLowerCase().includes(lower)) ??
440
+ all.find((m) => `${m.provider}/${m.id}`.toLowerCase().includes(lower))
441
+ );
442
+ }
443
+
444
+ async function switchModel(pi: ExtensionAPI, ctx: ExtensionContext, spec: string): Promise<boolean> {
445
+ const model = resolveModel(ctx, spec);
446
+ if (!model) {
447
+ ctx.ui.notify(`Loop: model not found: ${spec} (try provider/id, e.g. anthropic/claude-sonnet-4-5)`, "error");
448
+ return false;
449
+ }
450
+ const ok = await pi.setModel(model);
451
+ if (!ok) {
452
+ ctx.ui.notify(`Loop: no API key configured for ${model.provider}/${model.id}`, "error");
453
+ return false;
454
+ }
455
+ ctx.ui.notify(`Loop: model set to ${model.provider}/${model.id}`, "info");
456
+ return true;
457
+ }
458
+
459
+ /** Slightly varied continue prompts: identical repeated prompts encourage identical repeated answers. */
460
+ const CONTINUE_DIRECTIVES = [
461
+ "Continue loop mode.",
462
+ "Continue: execute the next concrete progress batch.",
463
+ "Keep going — pick the next step from your plan and do it now.",
464
+ "Proceed with the next focused unit of work on the goal.",
465
+ "Advance the goal with one concrete, verifiable change.",
466
+ ];
467
+
468
+ const STUCK_STRATEGIES = [
469
+ "Step back: list 3 genuinely different approaches in one line each, then execute the most promising one immediately.",
470
+ "Switch to a different subtask of the goal that you have not touched in the last few iterations.",
471
+ "Create or update PROGRESS.md with: current state, what was tried, what failed, next 3 steps. Then execute step 1.",
472
+ "Run the build and/or test suite, pick exactly one failure or warning, and fix only that.",
473
+ "Review recent changes (git diff / git log), verify correctness, and fix any issue you find.",
474
+ ];
475
+
476
+ function iterationLabel(): string {
477
+ const next = state.iterationCount + 1;
478
+ return state.maxIterations > 0 ? `${next}/${state.maxIterations}` : `${next}/∞`;
479
+ }
480
+
481
+ function kindDirective(kind: TurnKind): string {
482
+ switch (kind) {
483
+ case "start":
484
+ return state.preparedAt > 0
485
+ ? `Start loop mode. First read ${state.goalFile} to load the full specification, then do the first concrete progress batch.`
486
+ : "Start loop mode. Begin with a short plan (max 5 bullets) if useful, then do the first concrete progress batch.";
487
+ case "stuck": {
488
+ const strategy = STUCK_STRATEGIES[state.interventionCount % STUCK_STRATEGIES.length];
489
+ const escalation =
490
+ state.consecutiveStuckCount >= HARD_RESET_AFTER
491
+ ? ` HARD RESET (stuck intervention #${state.consecutiveStuckCount} in a row): forget your previous phrasing entirely. Banned openings: ${bannedOpenings()}. Your FIRST action this turn must be a tool call (read/bash/edit/write) that produces new information or a concrete file change — zero preamble text before it.`
492
+ : "";
493
+ return `Stuck intervention (${state.lastNotice}). You are repeating yourself. Do NOT repeat the previous answer, command, or question.${escalation} ${strategy}`;
494
+ }
495
+ case "recover":
496
+ return "The previous turn failed with a model/provider error and was retried automatically. Briefly re-establish your bearings (check files/tests as needed), then continue with the next concrete progress batch. Do not restart from scratch.";
497
+ case "improve":
498
+ return (
499
+ "You reported LOOP_DONE, but this loop runs in endless improvement mode. Work backlog-driven: open IMPROVEMENTS.md (create it if missing) — a checklist of concrete improvement items, each with affected file paths and a one-line acceptance criterion. " +
500
+ "If fewer than 3 open items remain, first inspect the codebase with tools and add new specific items. Vague items without file paths (e.g. \"add support for other platforms\") are forbidden. " +
501
+ "Then take the TOP open item, implement it now, and mark it done."
502
+ );
503
+ case "unblock":
504
+ return "You reported LOOP_BLOCKED, but no operator is available. Make the most reasonable assumption, record it in ASSUMPTIONS.md (create if missing), and continue working toward the goal. Never wait for a human.";
505
+ case "audit":
506
+ return `No concrete file/system changes were detected in the last ${NO_PROGRESS_WINDOW} iterations. Stop analyzing and produce a tangible artifact this turn: a file change, a passing test, a fixed bug, or a committed improvement.`;
507
+ case "resume":
508
+ return "The loop was resumed. Briefly check the current project state (files, tests, PROGRESS.md if present), then continue with the next concrete progress batch.";
509
+ case "regression":
510
+ return `Goal check regression: the score dropped to ${state.lastCheckScore} (best so far ${state.bestCheckScore} at iteration ${state.bestScoreIteration}). A recent change made things worse. Inspect recent changes (git diff / git log), find and fix the regression before doing anything else. Check output: ${state.lastCheckOutput}`;
511
+ case "check_failed":
512
+ return `You reported LOOP_DONE, but the goal check command still fails (streak ${state.checkFailStreak}). Completion is decided by the check, not by your claim. Fix exactly what the check reports. Check output: ${state.lastCheckOutput}`;
513
+ case "rescue":
514
+ return (
515
+ `RESCUE TURN: you are a stronger model called in because the loop model was stuck ${state.consecutiveStuckCount}x in a row (${state.lastNotice}). ` +
516
+ `Do now: 1) inspect the project state (git status/diff, PROGRESS.md, ${state.goalFile} if present); 2) fix or finish ONE concrete thing; ` +
517
+ `3) rewrite PROGRESS.md: current state, what keeps failing, the next 3 unambiguous steps with exact file paths; ` +
518
+ `4) update the IMPROVEMENTS.md backlog with concrete items (file paths + acceptance criterion). ` +
519
+ `End with one line "NEXT: <exact instruction for the next turn>". After this turn the loop returns to the regular model.`
520
+ );
521
+ default:
522
+ return CONTINUE_DIRECTIVES[Math.floor(Math.random() * CONTINUE_DIRECTIVES.length)];
523
+ }
524
+ }
525
+
526
+ function loopInstructions(kind: TurnKind): string {
527
+ const doneRule = state.untilDone
528
+ ? state.checkCommand
529
+ ? "- Completion is decided by the goal check command (exit code 0), not by your claim. Work until the check passes; you may still say \"LOOP_DONE:\" once it does."
530
+ : '- If the completion criteria are fully met, start your final message with "LOOP_DONE:".'
531
+ : '- Endless mode: if the core goal appears complete, say "LOOP_DONE: <one-line summary>" — the loop will then continue with improvement work (features, tests, bug fixes, refactoring, docs). Never stop on your own.';
532
+
533
+ const checkLine = state.checkCommand
534
+ ? `Goal check: \`${state.checkCommand}\` → ${
535
+ state.lastCheckPassed === undefined
536
+ ? "not run yet"
537
+ : state.lastCheckPassed
538
+ ? "PASSING"
539
+ : `FAILING (streak ${state.checkFailStreak})`
540
+ }${state.lastCheckScore !== undefined ? ` · score ${state.lastCheckScore} (best ${state.bestCheckScore} @ iteration ${state.bestScoreIteration})` : ""}\n`
541
+ : "";
542
+
543
+ return (
544
+ `${kindDirective(kind)}\n\n` +
545
+ `Goal: ${state.description}\n` +
546
+ `Completion criteria: ${state.completionCriteria || "continuous improvement until the operator stops the loop"}\n` +
547
+ (state.preparedAt > 0 ? `Specification: ${state.goalFile} — read it whenever you lose track of the plan.\n` : "") +
548
+ `Iteration: ${iterationLabel()}\n` +
549
+ `${checkLine}\n` +
550
+ `Rules:\n` +
551
+ `- Do exactly one concrete progress batch, then stop this turn.\n` +
552
+ `- Prefer tools and file changes over long explanations.\n` +
553
+ `- Never respond with narration only: every turn must include at least one tool call. Do not claim something "already exists" — verify it with a tool and then produce the next concrete change.\n` +
554
+ `- Hard output budget: max 1,200 characters; no large code blocks, logs, full diffs, or repeated context.\n` +
555
+ `${doneRule}\n` +
556
+ `- Never wait for a human. If information is missing, make the most reasonable assumption, document it in ASSUMPTIONS.md, and continue. Use "LOOP_BLOCKED:" only for truly impossible external barriers (e.g., missing credentials) — the loop will still continue with assumptions.\n` +
557
+ `- Otherwise briefly say what changed and let the loop continue.`
558
+ );
559
+ }
560
+
561
+ function sendLoopTurn(pi: ExtensionAPI, kind: TurnKind, ctx?: ExtensionContext): void {
562
+ const idle = ctx?.isIdle() ?? false;
563
+ const options = idle
564
+ ? { triggerTurn: true as const }
565
+ : { triggerTurn: true as const, deliverAs: "followUp" as const };
566
+
567
+ pi.sendMessage(
568
+ {
569
+ customType: MESSAGE_TYPE,
570
+ content: loopInstructions(kind),
571
+ display: true,
572
+ details: { kind, iteration: state.iterationCount + 1 },
573
+ },
574
+ options,
575
+ );
576
+ }
577
+
578
+ function scheduleLoopTurn(pi: ExtensionAPI, kind: TurnKind, delayMs: number, ctx?: ExtensionContext): void {
579
+ clearPendingTimer();
580
+ if (delayMs <= 0) {
581
+ sendLoopTurn(pi, kind, ctx);
582
+ return;
583
+ }
584
+ pendingTimer = setTimeout(() => {
585
+ pendingTimer = undefined;
586
+ if (!state.active) return;
587
+ // No ctx available in the timer; followUp + triggerTurn is safe both idle and busy.
588
+ sendLoopTurn(pi, kind);
589
+ }, delayMs);
590
+ }
591
+
592
+ function backoffSeconds(): number {
593
+ const exponent = Math.min(Math.max(state.consecutiveErrorCount - 1, 0), 6);
594
+ return Math.min(MAX_BACKOFF_SECONDS, BASE_BACKOFF_SECONDS * 2 ** exponent);
595
+ }
596
+
597
+ /** Appends one JSONL entry per loop event; used by /loop stats. Never throws. */
598
+ function logIteration(event: string, extra: Record<string, unknown> = {}): void {
599
+ try {
600
+ const entry = {
601
+ ts: new Date().toISOString(),
602
+ iteration: state.iterationCount,
603
+ event,
604
+ model: state.rescueActive ? state.rescueModel : state.loopModel || undefined,
605
+ score: state.lastCheckScore,
606
+ checkPassed: state.lastCheckPassed,
607
+ stuckStreak: state.consecutiveStuckCount,
608
+ notice: state.lastNotice || undefined,
609
+ ...extra,
610
+ };
611
+ appendFileSync(LOG_FILE, `${JSON.stringify(entry)}\n`);
612
+ } catch {
613
+ // Logging must never break the loop.
614
+ }
615
+ }
616
+
617
+ function statsText(): string {
618
+ let raw: string;
619
+ try {
620
+ raw = readFileSync(LOG_FILE, "utf8");
621
+ } catch {
622
+ return `No loop log found (${LOG_FILE}).`;
623
+ }
624
+ interface Entry {
625
+ ts: string;
626
+ event: string;
627
+ score?: number;
628
+ }
629
+ const all = raw
630
+ .split("\n")
631
+ .filter(Boolean)
632
+ .map((line) => {
633
+ try {
634
+ return JSON.parse(line) as Entry;
635
+ } catch {
636
+ return undefined;
637
+ }
638
+ })
639
+ .filter((entry): entry is Entry => Boolean(entry));
640
+ if (all.length === 0) return `Loop log is empty (${LOG_FILE}).`;
641
+ const current = state.startTime > 0 ? all.filter((entry) => Date.parse(entry.ts) >= state.startTime) : [];
642
+ const scope = current.length > 0 ? current : all;
643
+ const label = current.length > 0 ? "current run" : "all runs";
644
+ const counts = new Map<string, number>();
645
+ for (const entry of scope) counts.set(entry.event, (counts.get(entry.event) ?? 0) + 1);
646
+ const eventSummary = [...counts.entries()]
647
+ .sort((a, b) => b[1] - a[1])
648
+ .map(([key, value]) => `${key} ${value}`)
649
+ .join(", ");
650
+ const scores = scope.map((entry) => entry.score).filter((score): score is number => typeof score === "number");
651
+ const spanMs = Date.parse(scope[scope.length - 1].ts) - Date.parse(scope[0].ts);
652
+ const perHour = spanMs > 60_000 ? ((counts.get("continue") ?? 0) / (spanMs / 3_600_000)).toFixed(1) : "-";
653
+ const interventions =
654
+ (counts.get("stuck") ?? 0) +
655
+ (counts.get("audit") ?? 0) +
656
+ (counts.get("regression") ?? 0) +
657
+ (counts.get("rescue_start") ?? 0) +
658
+ (counts.get("compact") ?? 0);
659
+ return [
660
+ `Loop stats (${label}, ${scope.length} entries, ${LOG_FILE}):`,
661
+ `Events: ${eventSummary}`,
662
+ `Interventions: ${interventions} (rescue ${counts.get("rescue_start") ?? 0}, compact ${counts.get("compact") ?? 0})`,
663
+ `Productive iterations/h: ${perHour}`,
664
+ scores.length > 0 ? `Score: first ${scores[0]}, best ${Math.max(...scores)}, last ${scores[scores.length - 1]}` : "Score: -",
665
+ ].join("\n");
666
+ }
667
+
668
+ interface CheckOutcome {
669
+ passed: boolean;
670
+ score?: number;
671
+ output: string;
672
+ execFailed: boolean;
673
+ }
674
+
675
+ async function runGoalCheck(pi: ExtensionAPI): Promise<CheckOutcome> {
676
+ try {
677
+ const result = await pi.exec("bash", ["-lc", state.checkCommand], { timeout: state.checkTimeoutSeconds * 1000 });
678
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
679
+ const scoreMatches = [...output.matchAll(/SCORE:\s*(-?\d+(?:\.\d+)?)/gi)];
680
+ const score = scoreMatches.length > 0 ? Number.parseFloat(scoreMatches[scoreMatches.length - 1][1]) : undefined;
681
+ return { passed: result.code === 0, score, output: snippet(output, 400), execFailed: false };
682
+ } catch (error) {
683
+ return { passed: false, score: undefined, output: snippet(String(error), 200), execFailed: true };
684
+ }
685
+ }
686
+
687
+ /** Updates check state; returns true when the score regressed vs. the previous run. */
688
+ function applyCheckOutcome(outcome: CheckOutcome): boolean {
689
+ const previousScore = state.lastCheckScore;
690
+ state.lastCheckPassed = outcome.passed;
691
+ state.lastCheckOutput = outcome.output;
692
+ state.checkFailStreak = outcome.passed ? 0 : state.checkFailStreak + 1;
693
+ if (outcome.score !== undefined) {
694
+ state.lastCheckScore = outcome.score;
695
+ if (state.bestCheckScore === undefined || outcome.score > state.bestCheckScore) {
696
+ state.bestCheckScore = outcome.score;
697
+ state.bestScoreIteration = state.iterationCount;
698
+ // A better score is measurable progress; feeds the no-progress audit.
699
+ state.lastStateChangeIteration = state.iterationCount;
700
+ }
701
+ }
702
+ return outcome.score !== undefined && previousScore !== undefined && outcome.score < previousScore;
703
+ }
704
+
705
+ function detectStuck(lastAssistantText: string): string | undefined {
706
+ const prints = state.lastAssistantFingerprints;
707
+
708
+ // Degenerate generation: one sentence repeated many times within a single response.
709
+ const degenerate = degenerateRepetition(lastAssistantText, DEGENERATE_REPEATS);
710
+ if (degenerate) {
711
+ return `response degenerated: same sentence repeated ${degenerate.repeats}× ("${snippet(degenerate.sentence, 60)}")`;
712
+ }
713
+
714
+ // Narration-only loops: several turns without a single tool call.
715
+ if (state.turnsWithoutTools >= MAX_TOOLLESS_TURNS) {
716
+ return `no tool usage for ${state.turnsWithoutTools} turns (narration only)`;
717
+ }
718
+
719
+ const lastTwo = prints.slice(-2);
720
+ if (lastTwo.length === 2 && lastTwo[0] === lastTwo[1] && normalizeText(lastAssistantText).length > 80) {
721
+ return "assistant repeated the same response";
722
+ }
723
+
724
+ const lastThree = prints.slice(-3);
725
+ if (lastThree.length === 3 && lastThree.every((p) => p === lastThree[0]) && normalizeText(lastAssistantText).length > 0) {
726
+ return "assistant repeated the same response three times";
727
+ }
728
+
729
+ // Near-duplicate responses: exact fingerprints miss slight rephrasings ("…continue with improvements on X/Y").
730
+ const texts = state.lastAssistantTexts;
731
+ const previousText = texts.length >= 2 ? texts[texts.length - 2] : undefined;
732
+ if (previousText && normalizeText(lastAssistantText).length > 60) {
733
+ const similarity = textSimilarity(lastAssistantText, previousText);
734
+ if (similarity >= SIMILARITY_THRESHOLD) {
735
+ return `assistant response ~${Math.round(similarity * 100)}% similar to previous`;
736
+ }
737
+ }
738
+
739
+ // Alternating repetition (A-B-A-B…): same fingerprint several times in the recent window.
740
+ const currentPrint = prints[prints.length - 1];
741
+ if (currentPrint && prints.filter((p) => p === currentPrint).length >= REPEAT_WINDOW_COUNT) {
742
+ return `same response repeated ${REPEAT_WINDOW_COUNT}+ times in recent turns`;
743
+ }
744
+
745
+ const recentTools = state.recentToolResults.slice(-3);
746
+ if (
747
+ recentTools.length === 3 &&
748
+ recentTools.every((result) => result.tool === recentTools[0].tool && result.fingerprint === recentTools[0].fingerprint)
749
+ ) {
750
+ return recentTools.every((result) => result.isError)
751
+ ? `same ${recentTools[0].tool} error repeated`
752
+ : `same ${recentTools[0].tool} result repeated`;
753
+ }
754
+
755
+ const asksQuestion = /\?\s*$/.test(lastAssistantText.trim());
756
+ const lastSnippets = state.lastAssistantSnippets.slice(-2);
757
+ if (asksQuestion && lastSnippets.length === 2 && lastSnippets[0] === lastSnippets[1]) {
758
+ return "same question repeated";
759
+ }
760
+
761
+ return undefined;
762
+ }
763
+
764
+ function formatElapsed(): string {
765
+ if (state.startTime <= 0) return "0m";
766
+ let seconds = Math.round((Date.now() - state.startTime) / 1000);
767
+ const days = Math.floor(seconds / 86_400);
768
+ seconds -= days * 86_400;
769
+ const hours = Math.floor(seconds / 3_600);
770
+ seconds -= hours * 3_600;
771
+ const minutes = Math.floor(seconds / 60);
772
+ const parts: string[] = [];
773
+ if (days) parts.push(`${days}d`);
774
+ if (hours) parts.push(`${hours}h`);
775
+ parts.push(`${minutes}m`);
776
+ return parts.join(" ");
777
+ }
778
+
779
+ function statusBarText(): string {
780
+ const iterations = state.maxIterations > 0 ? `${state.iterationCount}/${state.maxIterations}` : `${state.iterationCount}/∞`;
781
+ const check =
782
+ state.lastCheckScore !== undefined
783
+ ? ` · score ${state.lastCheckScore}`
784
+ : state.lastCheckPassed !== undefined
785
+ ? ` · check ${state.lastCheckPassed ? "✓" : "✗"}`
786
+ : "";
787
+ return `Loop ${iterations} · ${formatElapsed()} · err ${state.totalErrorCount}${check}: ${snippet(state.description, 40)}`;
788
+ }
789
+
790
+ function statusText(ctx: ExtensionContext): string {
791
+ return [
792
+ `Active: ${state.active}`,
793
+ `Status: ${state.status}`,
794
+ `Goal: ${state.description || "-"}`,
795
+ `Criteria: ${state.completionCriteria || "- (endless improvement)"}`,
796
+ `Mode: ${state.untilDone ? "until-done" : "endless"}`,
797
+ `Iterations: ${state.iterationCount}${state.maxIterations > 0 ? `/${state.maxIterations}` : "/∞"}`,
798
+ `Delay: ${state.delaySeconds}s`,
799
+ `Check: ${state.checkCommand ? `${state.checkCommand} (timeout ${state.checkTimeoutSeconds}s)` : "-"}`,
800
+ `Check status: ${
801
+ state.lastCheckPassed === undefined ? "-" : state.lastCheckPassed ? "passing" : `failing (streak ${state.checkFailStreak})`
802
+ }${state.lastCheckScore !== undefined ? `, score ${state.lastCheckScore} (best ${state.bestCheckScore} @ iter ${state.bestScoreIteration})` : ""}`,
803
+ `Goal file: ${state.goalFile}${state.preparedAt > 0 ? " (prepared)" : " (not prepared)"}`,
804
+ `Loop model: ${state.loopModel || "- (current model)"}`,
805
+ `Rescue model: ${state.rescueModel || "-"}${state.rescueActive ? " (rescue turn in flight)" : ""}`,
806
+ `Elapsed: ${formatElapsed()}`,
807
+ `Errors: ${state.totalErrorCount} total, ${state.consecutiveErrorCount} consecutive`,
808
+ `Interventions: ${state.interventionCount} (stuck streak: ${state.consecutiveStuckCount})`,
809
+ `Done signals: ${state.doneSignalCount}, blocked signals: ${state.blockedSignalCount}`,
810
+ `Last notice: ${state.lastNotice || "-"}`,
811
+ `Session entries: ${ctx.sessionManager.getEntries().length}`,
812
+ ].join("\n");
813
+ }
814
+
815
+ function applyGoalConfig(parsed: StartArgs): void {
816
+ // Re-issuing the same goal (e.g. to tweak flags after /loop prepare) keeps the prepared spec.
817
+ const preservedPreparedAt = state.description === parsed.description ? state.preparedAt : 0;
818
+ state = {
819
+ ...defaultState(),
820
+ description: parsed.description,
821
+ completionCriteria: parsed.criteria,
822
+ maxIterations: parsed.maxIterations,
823
+ untilDone: parsed.untilDone,
824
+ delaySeconds: parsed.delaySeconds,
825
+ checkCommand: parsed.checkCommand,
826
+ checkTimeoutSeconds: parsed.checkTimeoutSeconds,
827
+ goalFile: parsed.goalFile || "GOAL.md",
828
+ loopModel: parsed.model,
829
+ rescueModel: parsed.rescueModel,
830
+ preparedAt: preservedPreparedAt,
831
+ status: "stopped",
832
+ };
833
+ }
834
+
835
+ function goalSummaryText(): string {
836
+ return [
837
+ `Goal: ${state.description || "-"}`,
838
+ `Criteria: ${state.completionCriteria || "- (endless improvement)"}`,
839
+ `Mode: ${state.untilDone ? "until-done" : "endless"}`,
840
+ `Max iterations: ${state.maxIterations > 0 ? state.maxIterations : "∞"}`,
841
+ `Delay: ${state.delaySeconds}s`,
842
+ `Check: ${state.checkCommand || "-"}`,
843
+ `Goal file: ${state.goalFile}`,
844
+ `Prepared: ${state.preparedAt > 0 ? new Date(state.preparedAt).toISOString() : "no (optional: /loop prepare [--model M])"}`,
845
+ `Loop model: ${state.loopModel || "- (current model)"}`,
846
+ `Rescue model: ${state.rescueModel || "-"}`,
847
+ ].join("\n");
848
+ }
849
+
850
+ function prepareInstructions(): string {
851
+ return (
852
+ `Prepare the loop goal specification. Do NOT start implementing the goal itself in this turn.\n\n` +
853
+ `Goal: ${state.description}\n` +
854
+ `Completion criteria: ${state.completionCriteria || "continuous improvement until the operator stops the loop"}\n\n` +
855
+ `Tasks for this turn:\n` +
856
+ `1. Inspect the current project state (files, README, tests) if one exists.\n` +
857
+ `2. Write ${state.goalFile} containing: refined objective, scope & non-goals, measurable completion criteria, a milestone roadmap of small steps, quality standards (tests, docs, git commits), and explicit assumptions.\n` +
858
+ `3. If the goal is objectively checkable, create a goal-check script (e.g. check.sh: exit 0 = criteria met, print "SCORE: <n>", higher = better) and reference it in ${state.goalFile}.\n` +
859
+ `4. Keep ${state.goalFile} under ~200 lines, concrete and unambiguous — it must guide another (possibly weaker) model through a long unattended run.\n\n` +
860
+ `End your final message with "GOAL_READY: <one-line summary>" and, if you created a check script, the exact --check command to use.`
861
+ );
862
+ }
863
+
864
+ /** Shared stuck handling with escalation ladder: penalties → rescue model → compaction → rotating strategy. */
865
+ async function interveneStuck(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): Promise<void> {
866
+ state.consecutiveStuckCount++;
867
+ state.interventionCount++;
868
+ state.status = "stuck";
869
+ state.lastNotice = reason;
870
+ state.turnsWithoutTools = 0;
871
+ // Fight repetition at the sampling level too (applied via before_provider_request).
872
+ state.penaltyTurnsRemaining = PENALTY_TURNS;
873
+
874
+ // Escalation 1: hand the situation to a stronger rescue model for one turn.
875
+ if (state.rescueModel && !state.rescueActive && state.consecutiveStuckCount >= RESCUE_AFTER) {
876
+ if (await switchModel(pi, ctx, state.rescueModel)) {
877
+ state.rescueActive = true;
878
+ persistState(pi);
879
+ logIteration("rescue_start", { reason });
880
+ ctx.ui.notify(`Loop: stuck ${state.consecutiveStuckCount}x — rescue turn with ${state.rescueModel}.`, "warning");
881
+ ctx.ui.setStatus("loop", `Loop rescue turn (stuck ${state.consecutiveStuckCount}x)`);
882
+ scheduleLoopTurn(pi, "rescue", 0, ctx);
883
+ return;
884
+ }
885
+ }
886
+
887
+ // Escalating pause between interventions to break tight garbage loops.
888
+ const delayMs = Math.min(60, 2 ** Math.min(state.consecutiveStuckCount, 6)) * 1000;
889
+
890
+ // Escalation 2: stubborn fixation — compact the context so the repeated pattern leaves the window.
891
+ if (state.consecutiveStuckCount >= COMPACT_AFTER && state.iterationCount - state.lastCompactIteration >= COMPACT_AFTER) {
892
+ state.lastCompactIteration = state.iterationCount;
893
+ persistState(pi);
894
+ logIteration("compact", { reason });
895
+ ctx.ui.notify(`Loop: stuck ${state.consecutiveStuckCount}x — compacting context to break the pattern.`, "warning");
896
+ ctx.compact({
897
+ customInstructions:
898
+ "Summarize the work so far concisely. Explicitly EXCLUDE repetitive filler sentences and repeated failed attempts; keep the goal, the current project state, and concrete next steps.",
899
+ onComplete: () => scheduleLoopTurn(pi, "stuck", 0),
900
+ onError: () => scheduleLoopTurn(pi, "stuck", delayMs),
901
+ });
902
+ return;
903
+ }
904
+
905
+ persistState(pi);
906
+ logIteration("stuck", { reason });
907
+ ctx.ui.notify(`Loop stuck (${state.consecutiveStuckCount}x): ${reason} — injecting new strategy.`, "warning");
908
+ ctx.ui.setStatus("loop", `Loop stuck ${state.consecutiveStuckCount}x — redirecting`);
909
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "stuck", delayMs, ctx);
910
+ }
911
+
912
+ function runLoop(pi: ExtensionAPI, ctx: ExtensionContext): void {
913
+ clearPendingTimer();
914
+ state.active = true;
915
+ state.startTime = Date.now();
916
+ state.iterationCount = 0;
917
+ state.consecutiveStuckCount = 0;
918
+ state.consecutiveErrorCount = 0;
919
+ state.totalErrorCount = 0;
920
+ state.interventionCount = 0;
921
+ state.doneSignalCount = 0;
922
+ state.blockedSignalCount = 0;
923
+ state.lastStateChangeIteration = 0;
924
+ state.lastAssistantFingerprints = [];
925
+ state.lastAssistantSnippets = [];
926
+ state.lastAssistantTexts = [];
927
+ state.recentToolResults = [];
928
+ state.turnsWithoutTools = 0;
929
+ state.toolCallsThisTurn = 0;
930
+ state.rescueActive = false;
931
+ state.penaltyTurnsRemaining = 0;
932
+ state.lastCompactIteration = 0;
933
+ state.status = "running";
934
+ state.lastNotice = "";
935
+
936
+ persistState(pi);
937
+ const mode = state.untilDone ? "until-done" : "endless (stop with /loop stop)";
938
+ ctx.ui.notify(`Loop active [${mode}]: ${state.description}`, "info");
939
+ ctx.ui.setStatus("loop", statusBarText());
940
+ sendLoopTurn(pi, "start", ctx);
941
+ }
942
+
943
+ export default function (pi: ExtensionAPI) {
944
+ pi.registerCommand("loop", {
945
+ description:
946
+ "Loop mode: /loop goal <goal> → /loop prepare [--model M] → /loop run [--model M]; or /loop start <goal>; /loop status|stats|resume|stop|end",
947
+ handler: async (args: string, ctx) => {
948
+ const trimmed = args.trim();
949
+ const [subcommand = "status", ...rest] = trimmed.split(/\s+/);
950
+ const command = subcommand.toLowerCase();
951
+ const remainder = rest.join(" ").trim();
952
+
953
+ if (command === "start") {
954
+ if (!remainder) {
955
+ ctx.ui.notify(
956
+ 'Usage: /loop start <goal[. Done when: criteria]> [--max N] [--delay S] [--check "CMD"] [--check-timeout S] [--model M] [--rescue-model M] [--until-done]',
957
+ "error",
958
+ );
959
+ return;
960
+ }
961
+ applyGoalConfig(parseStartArgs(remainder));
962
+ if (state.loopModel && !(await switchModel(pi, ctx, state.loopModel))) return;
963
+ runLoop(pi, ctx);
964
+ return;
965
+ }
966
+
967
+ if (command === "goal") {
968
+ if (!remainder) {
969
+ ctx.ui.notify(`Loop goal:\n${goalSummaryText()}`, "info");
970
+ return;
971
+ }
972
+ if (state.active) {
973
+ ctx.ui.notify("Loop is running. Use /loop stop first, then set a new goal.", "error");
974
+ return;
975
+ }
976
+ applyGoalConfig(parseStartArgs(remainder));
977
+ persistState(pi);
978
+ ctx.ui.notify(
979
+ `Goal set (not started):\n${goalSummaryText()}\n\nNext: /loop prepare [--model M] (optional), then /loop run [--model M].`,
980
+ "info",
981
+ );
982
+ return;
983
+ }
984
+
985
+ if (command === "prepare") {
986
+ if (!state.description) {
987
+ ctx.ui.notify("No goal set. Use /loop goal <goal> first.", "error");
988
+ return;
989
+ }
990
+ if (state.active) {
991
+ ctx.ui.notify("Loop is running. Use /loop stop first.", "error");
992
+ return;
993
+ }
994
+ const parsed = parseStartArgs(remainder);
995
+ if (parsed.goalFile) state.goalFile = parsed.goalFile;
996
+ if (parsed.model && !(await switchModel(pi, ctx, parsed.model))) return;
997
+ state.status = "preparing";
998
+ persistState(pi);
999
+ ctx.ui.notify(`Preparing goal specification in ${state.goalFile}… Review it when done, then /loop run [--model M].`, "info");
1000
+ pi.sendMessage(
1001
+ { customType: MESSAGE_TYPE, content: prepareInstructions(), display: true, details: { kind: "prepare" } },
1002
+ { triggerTurn: true },
1003
+ );
1004
+ return;
1005
+ }
1006
+
1007
+ if (command === "run") {
1008
+ if (!state.description) {
1009
+ ctx.ui.notify("No goal set. Use /loop goal <goal> first (or /loop start <goal> for one step).", "error");
1010
+ return;
1011
+ }
1012
+ if (state.active) {
1013
+ ctx.ui.notify("Loop is already running. Use /loop status to inspect it.", "error");
1014
+ return;
1015
+ }
1016
+ const parsed = parseStartArgs(remainder);
1017
+ if (parsed.model) state.loopModel = parsed.model;
1018
+ if (parsed.rescueModel) state.rescueModel = parsed.rescueModel;
1019
+ if (state.loopModel && !(await switchModel(pi, ctx, state.loopModel))) return;
1020
+ runLoop(pi, ctx);
1021
+ return;
1022
+ }
1023
+
1024
+ if (command === "resume") {
1025
+ if (!state.description) {
1026
+ ctx.ui.notify("No loop to resume. Use /loop start <goal>.", "error");
1027
+ return;
1028
+ }
1029
+ const parsed = parseStartArgs(remainder);
1030
+ if (remainder.includes("--max")) state.maxIterations = parsed.maxIterations;
1031
+ if (parsed.checkCommand) {
1032
+ state.checkCommand = parsed.checkCommand;
1033
+ state.checkTimeoutSeconds = parsed.checkTimeoutSeconds;
1034
+ }
1035
+ if (parsed.model) {
1036
+ state.loopModel = parsed.model;
1037
+ if (!(await switchModel(pi, ctx, parsed.model))) return;
1038
+ }
1039
+ if (parsed.rescueModel) state.rescueModel = parsed.rescueModel;
1040
+ if (state.maxIterations > 0 && state.iterationCount >= state.maxIterations) {
1041
+ state.maxIterations = 0;
1042
+ ctx.ui.notify("Iteration cap was exhausted; resuming without a cap (endless).", "warning");
1043
+ }
1044
+ state.active = true;
1045
+ state.status = "running";
1046
+ state.consecutiveStuckCount = 0;
1047
+ state.consecutiveErrorCount = 0;
1048
+ state.rescueActive = false;
1049
+ state.lastNotice = "Resumed by operator.";
1050
+ persistState(pi);
1051
+ ctx.ui.notify(`Loop resumed: ${state.description}`, "info");
1052
+ ctx.ui.setStatus("loop", statusBarText());
1053
+ sendLoopTurn(pi, "resume", ctx);
1054
+ return;
1055
+ }
1056
+
1057
+ if (command === "stop") {
1058
+ clearPendingTimer();
1059
+ state.active = false;
1060
+ state.status = "stopped";
1061
+ state.lastNotice = "Stopped by operator; state preserved.";
1062
+ persistState(pi);
1063
+ ctx.ui.notify("Loop stopped. Use /loop resume to continue, /loop start to replace, or /loop end to clear.", "info");
1064
+ ctx.ui.setStatus("loop", "Loop stopped");
1065
+ return;
1066
+ }
1067
+
1068
+ if (command === "end" || command === "clear") {
1069
+ clearPendingTimer();
1070
+ state = defaultState();
1071
+ persistState(pi);
1072
+ ctx.ui.notify("Loop ended and state cleared.", "info");
1073
+ ctx.ui.setStatus("loop", "Loop ended");
1074
+ return;
1075
+ }
1076
+
1077
+ if (command === "status") {
1078
+ ctx.ui.notify(`Loop state:\n${statusText(ctx)}`, "info");
1079
+ return;
1080
+ }
1081
+
1082
+ if (command === "stats") {
1083
+ ctx.ui.notify(statsText(), "info");
1084
+ return;
1085
+ }
1086
+
1087
+ if (command === "help") {
1088
+ ctx.ui.notify(
1089
+ "Workflow: /loop goal <goal> → /loop prepare [--model M] → /loop run [--model M]\n" +
1090
+ '/loop goal <goal[. Done when: criteria]> [--max N] [--delay S] [--check "CMD"] [--check-timeout S] [--file GOAL.md] [--model M] [--rescue-model M] [--until-done] — set goal without starting\n' +
1091
+ "/loop prepare [--model M] [--file F] — have a (strong) model write the goal spec + check script\n" +
1092
+ "/loop run [--model M] — start the loop, optionally with a different model\n" +
1093
+ "/loop start <goal> [flags] — goal + run in one step\n" +
1094
+ '/loop resume [--max N] [--check "CMD"] [--model M] [--rescue-model M] | /loop status | /loop stats | /loop stop | /loop end\n' +
1095
+ "--rescue-model M — stronger model that takes over for one turn after 3 stuck interventions in a row\n" +
1096
+ "Default: endless — runs until /loop stop. --until-done stops when the goal check passes (or LOOP_DONE without check).\n" +
1097
+ 'Goal check: shell command, exit 0 = done criteria met, optional "SCORE: n" output for progress/regression tracking.\n' +
1098
+ `Per-iteration JSONL log in ${LOG_FILE} — inspect with /loop stats.`,
1099
+ "info",
1100
+ );
1101
+ return;
1102
+ }
1103
+
1104
+ // Convenience: /loop <goal> starts a loop immediately.
1105
+ applyGoalConfig(parseStartArgs(trimmed));
1106
+ if (state.loopModel && !(await switchModel(pi, ctx, state.loopModel))) return;
1107
+ runLoop(pi, ctx);
1108
+ },
1109
+ });
1110
+
1111
+ pi.on("session_start", async (_event, ctx) => {
1112
+ clearPendingTimer();
1113
+ restoreState(ctx);
1114
+ if (!state.active) return;
1115
+
1116
+ ctx.ui.setStatus("loop", statusBarText());
1117
+
1118
+ // Unattended operation: auto-resume an active loop after restart/reload.
1119
+ if (["running", "stuck", "retrying"].includes(state.status)) {
1120
+ state.rescueActive = false;
1121
+ if (state.loopModel) {
1122
+ const model = resolveModel(ctx, state.loopModel);
1123
+ if (model) {
1124
+ const ok = await pi.setModel(model);
1125
+ if (!ok) ctx.ui.notify(`Loop: could not restore model ${state.loopModel} (no API key); using current model.`, "warning");
1126
+ } else {
1127
+ ctx.ui.notify(`Loop: stored model ${state.loopModel} not found; using current model.`, "warning");
1128
+ }
1129
+ }
1130
+ ctx.ui.notify(`Loop auto-resuming in ${AUTO_RESUME_DELAY_MS / 1000}s: ${snippet(state.description, 80)} (stop with /loop stop)`, "info");
1131
+ scheduleLoopTurn(pi, "resume", AUTO_RESUME_DELAY_MS);
1132
+ }
1133
+ });
1134
+
1135
+ pi.on("session_shutdown", async () => {
1136
+ clearPendingTimer();
1137
+ });
1138
+
1139
+ pi.on("before_agent_start", async (event) => {
1140
+ if (!state.active) return;
1141
+ const doneHint = state.untilDone
1142
+ ? "use LOOP_DONE: when the completion criteria are fully met"
1143
+ : "endless mode: after LOOP_DONE the loop continues with improvements, never stop on your own";
1144
+ return {
1145
+ systemPrompt:
1146
+ `${event.systemPrompt}\n\n` +
1147
+ `Loop mode is active. Goal: ${state.description}. Completion criteria: ${state.completionCriteria || "continuous improvement"}. ` +
1148
+ `Keep every assistant response under 1,200 characters, do one progress batch per turn, ` +
1149
+ `${doneHint}, never wait for a human (make documented assumptions instead), and never dump full logs/diffs/context.`,
1150
+ };
1151
+ });
1152
+
1153
+ // --- Anti-repetition sampling penalties for a few iterations after a stuck intervention. ---
1154
+ // Prompt-level interventions rarely break degenerate repetition; sampling penalties usually do.
1155
+ pi.on("before_provider_request", async (event, ctx) => {
1156
+ if (!state.active || state.penaltyTurnsRemaining <= 0) return;
1157
+ // Penalties are only reliable on OpenAI-compatible completions APIs (vLLM, Ollama, …).
1158
+ const api = String((ctx.model as { api?: string } | undefined)?.api ?? "");
1159
+ if (api !== "openai-completions") return;
1160
+ const payload = event.payload;
1161
+ if (!payload || typeof payload !== "object") return;
1162
+ const currentTemperature = (payload as { temperature?: number }).temperature;
1163
+ return {
1164
+ ...(payload as object),
1165
+ frequency_penalty: 0.5,
1166
+ presence_penalty: 0.5,
1167
+ temperature: Math.min(1.3, (currentTemperature ?? 0.7) + 0.2),
1168
+ };
1169
+ });
1170
+
1171
+ // --- Sanitize degenerate assistant messages before every LLM call. ---
1172
+ // A response full of one repeated sentence would otherwise reinforce the pattern each turn.
1173
+ pi.on("context", async (event) => {
1174
+ if (!state.active) return;
1175
+ let changed = false;
1176
+ const messages = event.messages.map((message) => {
1177
+ if ((message as { role?: string }).role !== "assistant") return message;
1178
+ const text = messageToText(message);
1179
+ if (text.length < 300) return message;
1180
+ const sanitized = sanitizeDegenerateText(text);
1181
+ if (!sanitized) return message;
1182
+ changed = true;
1183
+ return replaceMessageText(message as { content?: unknown }, sanitized) as typeof message;
1184
+ });
1185
+ return changed ? { messages } : undefined;
1186
+ });
1187
+
1188
+ // --- Mid-stream kill switch: abort runaway repetition instead of letting it fill the context. ---
1189
+ pi.on("message_start", async (event) => {
1190
+ if ((event.message as { role?: string }).role === "assistant") lastDegenerateCheckLength = 0;
1191
+ });
1192
+
1193
+ pi.on("message_update", async (event, ctx) => {
1194
+ if (!state.active || degenerateAbortPending) return;
1195
+ if ((event.message as { role?: string }).role !== "assistant") return;
1196
+ const text = messageToText(event.message);
1197
+ if (text.length - lastDegenerateCheckLength < DEGENERATE_CHECK_INTERVAL) return;
1198
+ lastDegenerateCheckLength = text.length;
1199
+ const info = degenerateRepetition(text, DEGENERATE_STREAM_REPEATS);
1200
+ if (info) {
1201
+ degenerateAbortPending = true;
1202
+ ctx.ui.notify(`Loop: degenerate repetition mid-stream (×${info.repeats}) — aborting turn.`, "warning");
1203
+ ctx.abort();
1204
+ }
1205
+ });
1206
+
1207
+ pi.on("tool_result", async (event) => {
1208
+ if (!state.active) return;
1209
+ const anyEvent = event as unknown as { toolName: string; content?: unknown; result?: { content?: unknown }; isError?: boolean };
1210
+ const text = contentToText(anyEvent.content ?? anyEvent.result?.content);
1211
+ recordToolResult(anyEvent.toolName, text, Boolean(anyEvent.isError));
1212
+ state.toolCallsThisTurn++;
1213
+ });
1214
+
1215
+ pi.on("message_end", async (event) => {
1216
+ if (!state.active || event.message.role !== "assistant") return;
1217
+ const stopReason = (event.message as { stopReason?: string }).stopReason;
1218
+ if (stopReason === "error" || stopReason === "aborted") return;
1219
+ const text = messageToText(event.message);
1220
+ if (!text.trim()) return;
1221
+ // Persist a truncated version of degenerate responses so they never poison the context.
1222
+ const sanitized = sanitizeDegenerateText(text);
1223
+ const tracked = sanitized ?? text;
1224
+ pushLimited(state.lastAssistantFingerprints, fingerprint(tracked), 8);
1225
+ pushLimited(state.lastAssistantSnippets, snippet(tracked), 5);
1226
+ pushLimited(state.lastAssistantTexts, tracked.slice(0, 1_500), 4);
1227
+ if (sanitized) {
1228
+ return { message: replaceMessageText(event.message as { content?: unknown }, sanitized) as typeof event.message };
1229
+ }
1230
+ });
1231
+
1232
+ pi.on("agent_end", async (event, ctx) => {
1233
+ if (!state.active) {
1234
+ // Goal preparation turn: watch for the readiness marker.
1235
+ if (state.status === "preparing") {
1236
+ const prepAssistant = [...event.messages].reverse().find((message) => message.role === "assistant");
1237
+ const prepText = messageToText(prepAssistant);
1238
+ if (/\bGOAL_READY\s*:/i.test(prepText)) {
1239
+ state.preparedAt = Date.now();
1240
+ state.status = "stopped";
1241
+ state.lastNotice = "Goal prepared.";
1242
+ persistState(pi);
1243
+ ctx.ui.notify(`Goal preparation complete. Review ${state.goalFile}, then start with /loop run [--model M].`, "info");
1244
+ }
1245
+ }
1246
+ return;
1247
+ }
1248
+ clearPendingTimer();
1249
+
1250
+ const lastAssistant = [...event.messages].reverse().find((message) => message.role === "assistant") as
1251
+ | { role: string; content?: unknown; stopReason?: string; errorMessage?: string }
1252
+ | undefined;
1253
+ const lastAssistantText = messageToText(lastAssistant);
1254
+ const stopReason = lastAssistant?.stopReason;
1255
+
1256
+ // --- Model/provider errors: retry with exponential backoff, never give up. ---
1257
+ if (!lastAssistant || stopReason === "error") {
1258
+ state.consecutiveErrorCount++;
1259
+ state.totalErrorCount++;
1260
+ state.status = "retrying";
1261
+ const delay = backoffSeconds();
1262
+ const reason = snippet(lastAssistant?.errorMessage ?? lastAssistantText ?? "no assistant message", 140);
1263
+ state.lastNotice = `Model/provider error (${reason}); retry #${state.consecutiveErrorCount} in ${delay}s.`;
1264
+ persistState(pi);
1265
+ logIteration("error", { reason });
1266
+ ctx.ui.notify(`Loop: model error, retrying in ${delay}s (attempt ${state.consecutiveErrorCount}): ${reason}`, "warning");
1267
+ ctx.ui.setStatus("loop", `Loop retrying in ${delay}s (err #${state.totalErrorCount})`);
1268
+ scheduleLoopTurn(pi, "recover", delay * 1000);
1269
+ return;
1270
+ }
1271
+
1272
+ // --- Degenerate-repetition abort (ours, not the operator's): treat as stuck, keep looping. ---
1273
+ if (stopReason === "aborted" && degenerateAbortPending) {
1274
+ degenerateAbortPending = false;
1275
+ await interveneStuck(pi, ctx, "response degenerated into repeating one sentence; turn aborted mid-stream");
1276
+ return;
1277
+ }
1278
+
1279
+ // --- Operator abort (Esc): respect it, but keep state for /loop resume. ---
1280
+ if (stopReason === "aborted") {
1281
+ state.status = "paused";
1282
+ state.lastNotice = "Turn aborted by operator. Use /loop resume to continue.";
1283
+ persistState(pi);
1284
+ logIteration("operator_abort");
1285
+ ctx.ui.notify("Loop paused (turn aborted). Use /loop resume to continue.", "warning");
1286
+ ctx.ui.setStatus("loop", "Loop paused (aborted)");
1287
+ return;
1288
+ }
1289
+
1290
+ state.consecutiveErrorCount = 0;
1291
+ state.iterationCount++;
1292
+
1293
+ // Track narration-only turns (no tool calls at all).
1294
+ if (state.toolCallsThisTurn === 0) {
1295
+ state.turnsWithoutTools++;
1296
+ } else {
1297
+ state.turnsWithoutTools = 0;
1298
+ }
1299
+ state.toolCallsThisTurn = 0;
1300
+
1301
+ // --- Rescue turn finished: hand control back to the regular loop model. ---
1302
+ if (state.rescueActive) {
1303
+ state.rescueActive = false;
1304
+ state.consecutiveStuckCount = 0;
1305
+ if (state.loopModel) await switchModel(pi, ctx, state.loopModel);
1306
+ state.status = "running";
1307
+ state.lastNotice = "Rescue turn completed; back to loop model.";
1308
+ persistState(pi);
1309
+ logIteration("rescue_end");
1310
+ ctx.ui.setStatus("loop", statusBarText());
1311
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "continue", state.delaySeconds * 1000, ctx);
1312
+ return;
1313
+ }
1314
+
1315
+ // --- Objective goal function (if configured). ---
1316
+ let scoreRegressed = false;
1317
+ if (state.checkCommand) {
1318
+ const outcome = await runGoalCheck(pi);
1319
+ if (outcome.execFailed) {
1320
+ ctx.ui.notify(`Loop: goal check could not run: ${outcome.output}`, "warning");
1321
+ }
1322
+ scoreRegressed = applyCheckOutcome(outcome);
1323
+
1324
+ // Verified completion: in until-done mode the check decides, not the model.
1325
+ if (state.untilDone && outcome.passed && !outcome.execFailed) {
1326
+ state.active = false;
1327
+ state.status = "completed";
1328
+ state.lastNotice = `Goal check passed: ${state.checkCommand}`;
1329
+ persistState(pi);
1330
+ logIteration("completed", { by: "check" });
1331
+ ctx.ui.notify(`Loop completed — goal check passed: ${state.description}`, "info");
1332
+ ctx.ui.setStatus("loop", "Loop completed (check passed)");
1333
+ return;
1334
+ }
1335
+ }
1336
+
1337
+ // --- Completion marker. ---
1338
+ if (/\bLOOP_DONE\s*:/i.test(lastAssistantText)) {
1339
+ state.doneSignalCount++;
1340
+ if (state.untilDone) {
1341
+ if (state.checkCommand && state.lastCheckPassed === false) {
1342
+ state.status = "running";
1343
+ state.lastNotice = `LOOP_DONE claimed but goal check fails (streak ${state.checkFailStreak}).`;
1344
+ persistState(pi);
1345
+ logIteration("check_failed");
1346
+ ctx.ui.notify("Loop: LOOP_DONE claimed, but the goal check fails — continuing.", "warning");
1347
+ ctx.ui.setStatus("loop", statusBarText());
1348
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "check_failed", state.delaySeconds * 1000, ctx);
1349
+ return;
1350
+ }
1351
+ state.active = false;
1352
+ state.status = "completed";
1353
+ state.lastNotice = "Completion marker seen (until-done mode).";
1354
+ persistState(pi);
1355
+ logIteration("completed", { by: "marker" });
1356
+ ctx.ui.notify(`Loop completed: ${state.description}`, "info");
1357
+ ctx.ui.setStatus("loop", "Loop completed");
1358
+ return;
1359
+ }
1360
+ state.status = "running";
1361
+ state.lastNotice = `Done signal #${state.doneSignalCount}; continuing with improvements.`;
1362
+ persistState(pi);
1363
+ logIteration("done");
1364
+ ctx.ui.notify(`Loop: goal reported done (#${state.doneSignalCount}); continuing with improvement work.`, "info");
1365
+ ctx.ui.setStatus("loop", statusBarText());
1366
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "improve", state.delaySeconds * 1000, ctx);
1367
+ return;
1368
+ }
1369
+
1370
+ // --- Blocked marker: never wait for the operator; force assumptions. ---
1371
+ if (/\bLOOP_BLOCKED\s*:/i.test(lastAssistantText)) {
1372
+ state.blockedSignalCount++;
1373
+ state.status = "running";
1374
+ state.lastNotice = `Blocked signal #${state.blockedSignalCount}; instructed to assume and continue.`;
1375
+ persistState(pi);
1376
+ logIteration("blocked");
1377
+ ctx.ui.notify(`Loop: blocked reported (${snippet(lastAssistantText, 120)}); continuing with assumptions.`, "warning");
1378
+ ctx.ui.setStatus("loop", statusBarText());
1379
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "unblock", state.delaySeconds * 1000, ctx);
1380
+ return;
1381
+ }
1382
+
1383
+ // --- Optional iteration cap (only when --max was given). ---
1384
+ if (state.maxIterations > 0 && state.iterationCount >= state.maxIterations) {
1385
+ state.active = false;
1386
+ state.status = "paused";
1387
+ state.lastNotice = `Paused after max iterations (${state.maxIterations}).`;
1388
+ persistState(pi);
1389
+ logIteration("max_reached");
1390
+ ctx.ui.notify(`Loop paused after ${state.maxIterations} iterations. Use /loop resume [--max N] to continue.`, "warning");
1391
+ ctx.ui.setStatus("loop", "Loop paused (max iterations)");
1392
+ return;
1393
+ }
1394
+
1395
+ // --- Score regression: a change made the objective measure worse. ---
1396
+ if (scoreRegressed) {
1397
+ state.interventionCount++;
1398
+ state.status = "running";
1399
+ state.lastNotice = `Score regression: ${state.lastCheckScore} (best ${state.bestCheckScore}).`;
1400
+ persistState(pi);
1401
+ logIteration("regression");
1402
+ ctx.ui.notify(`Loop: goal check score regressed to ${state.lastCheckScore} — requesting fix.`, "warning");
1403
+ ctx.ui.setStatus("loop", statusBarText());
1404
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "regression", state.delaySeconds * 1000, ctx);
1405
+ return;
1406
+ }
1407
+
1408
+ // --- Stuck detection: intervene with rotating strategies, never pause. ---
1409
+ const stuckReason = detectStuck(lastAssistantText);
1410
+ if (stuckReason) {
1411
+ await interveneStuck(pi, ctx, stuckReason);
1412
+ return;
1413
+ }
1414
+
1415
+ // --- No-progress audit: analysis-only loops must produce artifacts. ---
1416
+ if (state.iterationCount - state.lastStateChangeIteration >= NO_PROGRESS_WINDOW) {
1417
+ state.lastStateChangeIteration = state.iterationCount;
1418
+ state.interventionCount++;
1419
+ state.status = "running";
1420
+ state.lastNotice = `No concrete changes for ${NO_PROGRESS_WINDOW} iterations; audit nudge sent.`;
1421
+ persistState(pi);
1422
+ logIteration("audit");
1423
+ ctx.ui.notify(`Loop: no concrete progress for ${NO_PROGRESS_WINDOW} iterations — requesting tangible output.`, "warning");
1424
+ ctx.ui.setStatus("loop", statusBarText());
1425
+ if (!ctx.hasPendingMessages()) scheduleLoopTurn(pi, "audit", state.delaySeconds * 1000, ctx);
1426
+ return;
1427
+ }
1428
+
1429
+ // --- Normal continue. ---
1430
+ state.consecutiveStuckCount = 0;
1431
+ if (state.penaltyTurnsRemaining > 0) state.penaltyTurnsRemaining--;
1432
+ state.status = "running";
1433
+ state.lastNotice = "";
1434
+ persistState(pi);
1435
+ logIteration("continue");
1436
+ ctx.ui.setStatus("loop", statusBarText());
1437
+
1438
+ if (!ctx.hasPendingMessages()) {
1439
+ scheduleLoopTurn(pi, "continue", state.delaySeconds * 1000, ctx);
1440
+ }
1441
+ });
1442
+ }