pi-goal-list-loop-audit 0.26.0 → 0.26.1

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.
@@ -11,6 +11,17 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { execSync } from "node:child_process";
13
13
 
14
+ /** v0.26.1: consecutive heartbeat refires without a real agent turn
15
+ * before the supervisor gives up (pauses the goal / stops the loop).
16
+ * 0 = never escalate (legacy silent-spin behavior). */
17
+ export const DEFAULT_STALL_ESCALATION_REFIRES = 5;
18
+
19
+ /** v0.26.1: pure gate — has the refire streak hit the escalation
20
+ * threshold? threshold 0 disables escalation entirely. */
21
+ export function shouldEscalateStall(consecutiveStalls: number, threshold: number): boolean {
22
+ return threshold > 0 && consecutiveStalls >= threshold;
23
+ }
24
+
14
25
  // =================================================================
15
26
  // Types
16
27
  // =================================================================
@@ -81,17 +81,20 @@ export interface AuditDisplayProgress {
81
81
  * One-line status for ctx.ui.setStatus("pi-glla", …).
82
82
  * Returns undefined when nothing is being supervised (clears the segment).
83
83
  */
84
- export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme): string | undefined {
84
+ export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, extras?: { stalls?: number }): string | undefined {
85
85
  if (state.loop?.active) {
86
86
  const l = state.loop;
87
+ // v0.26.1: surface the refire streak — a spinning supervisor is the
88
+ // zombie signature (hegemon incident: 619 refires, 0 turns).
89
+ const stallSuffix = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
87
90
  // v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
88
91
  if (!l.measureCmd) {
89
- return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless`;
92
+ return `glla: loop ${paint(theme, "accent", "∞")} iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · metricless${stallSuffix}`;
90
93
  }
91
94
  const arrow = paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
92
95
  const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
93
96
  const stall = l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText;
94
- return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}`;
97
+ return `glla: loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · best ${l.bestValue ?? "n/a"} · ${stall}${stallSuffix}`;
95
98
  }
96
99
  const g = state.goal;
97
100
  if (!g) return undefined;
@@ -146,8 +149,8 @@ function countTotal(g: Goal): number {
146
149
  * Widget lines for ctx.ui.setWidget("pi-glla", lines).
147
150
  * Returns undefined when nothing is worth showing.
148
151
  */
149
- export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number): string[] | undefined {
150
- if (state.loop?.active) return loopLines(state.loop, now, theme, width);
152
+ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
153
+ if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
151
154
  const g = state.goal;
152
155
  if (!g) return undefined;
153
156
  if (g.status === "complete" || g.status === "aborted") return undefined;
@@ -202,12 +205,14 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
202
205
  return lines;
203
206
  }
204
207
 
205
- function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number): string[] {
208
+ function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] {
209
+ // v0.26.1: the refire streak, shown only while nonzero.
210
+ const stallNote = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
206
211
  // v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
207
212
  if (!l.measureCmd) {
208
213
  const lines = [
209
214
  `${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
210
- `├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
215
+ `├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}${stallNote}`,
211
216
  `└─ ${paint(theme, "dim", "metricless — work the spec (no plateau)")}`,
212
217
  ];
213
218
  if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
@@ -49,6 +49,9 @@ export interface Settings {
49
49
  /** Consecutive stuck interventions before a loop stops (default 5,
50
50
  * 10 under aggressiveMode). */
51
51
  stuckMaxInterventions?: number;
52
+ /** v0.26.1: consecutive heartbeat refires without a real turn before
53
+ * the goal pauses / loop stops (default 5; 0 = never escalate). */
54
+ stallEscalationRefires?: number;
52
55
  /** on → propose_* drafts activate WITHOUT the Confirm dialog and the
53
56
  * interview floor is skipped — the seed carries the intent (unattended
54
57
  * rigs). Default off: nothing activates before the user confirms. */
@@ -127,6 +130,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
127
130
  "aggressiveMode",
128
131
  "quotaRetryMinutes",
129
132
  "stuckMaxInterventions",
133
+ "stallEscalationRefires",
130
134
  ];
131
135
 
132
136
  /** Where each effective setting comes from (for the /glla display). */
@@ -33,6 +33,7 @@ import {
33
33
  auditFeedbackExcerpt,
34
34
  DEFAULT_AUDIT_FEEDBACK_CHARS,
35
35
  DEFAULT_QUOTA_RETRY_MINUTES,
36
+ DEFAULT_STALL_ESCALATION_REFIRES,
36
37
  DEFAULT_TOKEN_LIMIT,
37
38
  classifyImpossibleReason,
38
39
  extractPendingTasks,
@@ -50,6 +51,7 @@ import {
50
51
  ledgerPath,
51
52
  crossRecommendMode,
52
53
  formatListDepth,
54
+ shouldEscalateStall,
53
55
  shouldSuppressHeartbeatForRecentShip,
54
56
  mergeSettings,
55
57
  parseListImport,
@@ -231,10 +233,16 @@ const countedLoopTokenMessages = new Set<string>();
231
233
  let lastActivityAt = Date.now();
232
234
  let lastWedgeAlertAt = 0;
233
235
  let heartbeatNudges = 0;
236
+ // v0.26.1: consecutive heartbeat refires that produced NO real agent turn.
237
+ // Resets only on real activity (agent_end / tool_call) — never on the
238
+ // refire's own noteActivity, which is what made the hegemon zombie spin
239
+ // self-sustaining (619 refires / 23.5h / zero turns).
240
+ let consecutiveStalls = 0;
234
241
  let heartbeatTimer: NodeJS.Timeout | null = null;
235
242
 
236
- function noteActivity(): void {
243
+ function noteActivity(real = false): void {
237
244
  lastActivityAt = Date.now();
245
+ if (real) consecutiveStalls = 0;
238
246
  }
239
247
 
240
248
  function isSupervising(): boolean {
@@ -256,8 +264,8 @@ function refreshUI(ctx: ExtensionContext): void {
256
264
  // Terminal width for truncation budgets: on wide terminals the widget
257
265
  // uses the room instead of cutting at fixed ~60-char floors.
258
266
  const width = process.stdout.columns || 80;
259
- ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme));
260
- ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width));
267
+ ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme, { stalls: consecutiveStalls }));
268
+ ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width, { stalls: consecutiveStalls }));
261
269
  } catch {
262
270
  // stale ctx — next event refreshes
263
271
  }
@@ -323,8 +331,36 @@ function heartbeatTick(): void {
323
331
  return;
324
332
  }
325
333
  noteActivity();
326
- appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges });
327
- ctx.ui.notify("Heartbeat: supervisor active but session stalled — re-firing continuation.", "info");
334
+ consecutiveStalls++;
335
+ appendLedger(ctx.cwd, "heartbeat_refire", { nudgesSoFar: heartbeatNudges, consecutiveStalls });
336
+ // v0.26.1: a refire streak means the continuation is NOT landing (wedged
337
+ // message queue, stale API handle, dead turn trigger). Nudges can't catch
338
+ // this — they count turns, and a zombie runs none. Escalate to a loud,
339
+ // actionable stop instead of spinning silently forever.
340
+ const stallEscalation = loadSettings(ctx.cwd).stallEscalationRefires ?? DEFAULT_STALL_ESCALATION_REFIRES;
341
+ if (shouldEscalateStall(consecutiveStalls, stallEscalation)) {
342
+ consecutiveStalls = 0;
343
+ appendLedger(ctx.cwd, "stall_escalated", { threshold: stallEscalation, kind: isLoopActive() ? "loop" : "goal" });
344
+ if (isLoopActive()) {
345
+ clearLoopTimer();
346
+ state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${stallEscalation} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Restart pi, then /loop start again.` };
347
+ persistState(ctx);
348
+ ctx.ui.notify(`Loop stopped: ${stallEscalation} refires produced no turn — the continuation is not landing. Restart pi and /loop start.`, "warning");
349
+ notifyExternal(ctx, "Loop stopped: stalled (continuation not landing).");
350
+ return;
351
+ }
352
+ if (state.goal && state.goal.status === "active") {
353
+ updateGoal({
354
+ status: "paused",
355
+ pauseReason: `stalled: ${stallEscalation} continuation refires landed no turn`,
356
+ pauseSuggestedAction: "The continuation chain is broken in this process (wedged message queue or stale API). Restart pi, then /goal resume.",
357
+ }, ctx);
358
+ ctx.ui.notify(`Goal paused: ${stallEscalation} refires produced no turn. Restart pi, then /goal resume.`, "warning");
359
+ notifyExternal(ctx, "Goal paused: stalled (continuation not landing).");
360
+ return;
361
+ }
362
+ }
363
+ ctx.ui.notify(`Heartbeat: supervisor active but session stalled — re-firing continuation (stall ${consecutiveStalls}/${stallEscalation > 0 ? stallEscalation : "∞"}).`, "info");
328
364
  if (isLoopActive()) {
329
365
  scheduleLoopTick(ctx);
330
366
  } else {
@@ -415,7 +451,9 @@ function sendContinuation(goalId: string): void {
415
451
  content: continuationPrompt(state.goal!),
416
452
  display: false,
417
453
  }, { triggerTurn: true, deliverAs: "followUp" });
418
- } catch {
454
+ appendLedger(ctx.cwd, "goal_continuation_sent", { goalId });
455
+ } catch (err) {
456
+ appendLedger(ctx.cwd, "goal_continuation_send_failed", { goalId, error: err instanceof Error ? err.message : String(err) });
419
457
  // API went stale mid-flight; next agent_end/session_start will reschedule.
420
458
  }
421
459
  }
@@ -1300,8 +1338,13 @@ function sendLoopTurn(): void {
1300
1338
  content: loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
1301
1339
  display: false,
1302
1340
  }, { triggerTurn: true, deliverAs: "followUp" });
1303
- } catch {
1304
- // stale API next agent_end reschedules
1341
+ // v0.26.1: the send path is ledgered — the hegemon zombie spun 619
1342
+ // refires with zero visibility into whether sends were landing.
1343
+ appendLedger(ctx.cwd, "loop_turn_sent", { iteration: loop.iteration });
1344
+ } catch (err) {
1345
+ // stale API — next agent_end reschedules (but if none comes, the
1346
+ // heartbeat's stall escalation stops the spin — v0.26.1).
1347
+ appendLedger(ctx.cwd, "loop_turn_send_failed", { error: err instanceof Error ? err.message : String(err) });
1305
1348
  }
1306
1349
  }
1307
1350
 
@@ -3153,6 +3196,19 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
3153
3196
  ctx.ui.notify(`quotaretryminutes must be a positive integer, got: ${value}`, "warning");
3154
3197
  }
3155
3198
  }
3199
+ } else if (key === "stallescalation" || key === "stallescalationrefires") {
3200
+ if (["unset", "default"].includes(value)) {
3201
+ patch.stallEscalationRefires = undefined;
3202
+ changed = true;
3203
+ } else {
3204
+ const n = Number.parseInt(value, 10);
3205
+ if (Number.isInteger(n) && n >= 0) {
3206
+ patch.stallEscalationRefires = n;
3207
+ changed = true;
3208
+ } else {
3209
+ ctx.ui.notify(`stallescalation must be a non-negative integer (0 = never escalate), got: ${value}`, "warning");
3210
+ }
3211
+ }
3156
3212
  } else if (key === "stuckmax" || key === "stuckmaxinterventions") {
3157
3213
  if (["unset", "default"].includes(value)) {
3158
3214
  patch.stuckMaxInterventions = undefined;
@@ -3302,6 +3358,7 @@ export default function (pi: ExtensionAPI): void {
3302
3358
  ["aggressivemode=", "on: keep-going defaults — autoResume, cap 10, stuck 10, wedge off, quota auto-retry, cap→TODOs"],
3303
3359
  ["quotaretryminutes=", "N: minutes before auto-retrying a quota-exhausted auditor (default 60)"],
3304
3360
  ["stuckmax=", "N: consecutive stuck interventions before a loop stops (default 5)"],
3361
+ ["stallescalation=", "N: heartbeat refires without a turn before goal pauses / loop stops (default 5, 0 = never)"],
3305
3362
  ["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
3306
3363
  ["audits", "audit-log browser: /glla audits [N|full] — recent verdicts from .pi-glla/audits.jsonl"],
3307
3364
  ["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
@@ -3367,6 +3424,30 @@ export default function (pi: ExtensionAPI): void {
3367
3424
  }
3368
3425
  }
3369
3426
 
3427
+ // v0.26.1: compaction ends WITHOUT an agent_end (the compaction turn is
3428
+ // not an agent turn), so the continuation chain can dangle until the
3429
+ // 60s heartbeat notices. Re-arm it as soon as pi settles post-compact.
3430
+ pi.on("session_compact", async (_event: any, ctx: ExtensionContext) => {
3431
+ if (isForeignCtx(ctx)) return;
3432
+ rememberCtx(ctx);
3433
+ if (!isSupervising()) return;
3434
+ appendLedger(ctx.cwd, "session_compact", {});
3435
+ const settle = setTimeout(() => {
3436
+ const c = freshCtx();
3437
+ if (!c) return;
3438
+ try {
3439
+ if (c.isIdle() && !c.hasPendingMessages() && continuationTimer === null && loopTimer === null && isSupervising()) {
3440
+ appendLedger(c.cwd, "compaction_refire", {});
3441
+ if (isLoopActive()) scheduleLoopTick(c);
3442
+ else scheduleContinuation(c, true);
3443
+ }
3444
+ } catch {
3445
+ /* settle race — the 60s heartbeat covers it */
3446
+ }
3447
+ }, 2000);
3448
+ settle.unref?.();
3449
+ });
3450
+
3370
3451
  pi.on("message_start", async (event: any, _ctx: ExtensionContext) => {
3371
3452
  // v0.14.0 drafting floor: count real user replies while drafting. Our
3372
3453
  // own injected draft prompt arrives as a user message — skip that one.
@@ -3549,7 +3630,7 @@ export default function (pi: ExtensionAPI): void {
3549
3630
  // v0.23.8: a subagent finishing must not drive the main session's
3550
3631
  // continuation loop.
3551
3632
  if (isForeignCtx(ctx)) return;
3552
- noteActivity();
3633
+ noteActivity(true);
3553
3634
  // v0.25.2: per-goal turn telemetry (/glla stats).
3554
3635
  if (state.goal && state.goal.status === "active") {
3555
3636
  const t = state.goal.telemetry ?? { turns: 0, fileWrites: 0, bashCalls: 0 };
@@ -3650,7 +3731,7 @@ export default function (pi: ExtensionAPI): void {
3650
3731
 
3651
3732
  pi.on("tool_call", () => {
3652
3733
  toolCallsThisTurn++;
3653
- noteActivity();
3734
+ noteActivity(true);
3654
3735
  // v0.24.0: count loop-iteration tool calls (narration-only detection).
3655
3736
  if (isLoopActive()) {
3656
3737
  state.loop!.toolsThisTurn = (state.loop!.toolsThisTurn ?? 0) + 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",