pi-goal-list-loop-audit 0.34.11 → 0.34.13

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.
@@ -202,7 +202,14 @@ export function buildStatusText(state: State, audit?: AuditDisplayProgress | nul
202
202
  const kind = pauseKind(g);
203
203
  if (kind === "decision") return `glla: ${paint(theme, "accent", "⏸ decision needed")}${heldSuffix}`;
204
204
  if (kind === "error") return `glla: ${paint(theme, "error", `⏸ action needed — ${truncate(g.pauseReason ?? "", 30)}`)}${heldSuffix}`;
205
- if (kind === "wait") return `glla: ${paint(theme, "dim", `⏳ waiting${g.pauseResumeAt ? ` · resumes ${shortClock(g.pauseResumeAt)}` : ""}`)}${heldSuffix}`;
205
+ if (kind === "wait") {
206
+ // v0.34.12: live countdown (the UI ticker keeps rendering through a
207
+ // timed wait) — "resumes in 23m" beats a static clock time, and a
208
+ // passed resumeAt says "resuming…" instead of lying about the past.
209
+ const rms = g.pauseResumeAt ? Date.parse(g.pauseResumeAt) - now : Number.NaN;
210
+ const when = Number.isNaN(rms) ? "" : rms <= 0 ? " · resuming…" : ` · resumes in ${fmtElapsed(rms)}`;
211
+ return `glla: ${paint(theme, "dim", `⏳ waiting${when}`)}${heldSuffix}`;
212
+ }
206
213
  const label = `paused ⏸ ${truncate(g.pauseReason ?? "", 40)}`;
207
214
  return `glla: ${paint(theme, pauseIsError(g) ? "error" : "warning", label)}${heldSuffix}`;
208
215
  }
@@ -73,6 +73,14 @@ export interface Settings {
73
73
  * assertActive). Default true; only acts when TMUX and TMUX_PANE are
74
74
  * set — pi outside tmux just gets the manual warning. */
75
75
  autoReloadOnStale?: boolean;
76
+ /** v0.34.13: auto-recovery ladder — when a wedge is detected that only a
77
+ * /reload cures (unanswered continuation, send-retry storm), inject the
78
+ * /reload ITSELF via the v0.29.13 tmux/WezTerm transport and resume the
79
+ * goal/loop after the rebuild (sidecar marker — autoresume=off is a
80
+ * restore-time setting, not a recovery veto). Default true. The one
81
+ * class it does NOT cross: pi restart (transcript-writer dead) — that
82
+ * stays a loud stop for the human. */
83
+ autoRecovery?: boolean;
76
84
  /** v0.26.1: consecutive heartbeat refires without a real turn before
77
85
  * the goal pauses / loop stops (default 5; 0 = never escalate). */
78
86
  stallEscalationRefires?: number;
@@ -207,6 +215,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
207
215
  "stuckMaxInterventions",
208
216
  "stallEscalationRefires",
209
217
  "autoReloadOnStale",
218
+ "autoRecovery",
210
219
  "stallShortWords",
211
220
  "stallSimilarityThreshold",
212
221
  "postaudit",
@@ -346,9 +346,9 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
346
346
  * fires from the entry-probe path (the most common stale discovery).
347
347
  * Opt out: autoReloadOnStale=false. Best-effort: the manual warning
348
348
  * already fired, so failures cost nothing. */
349
- function attemptAutoReload(ctx: ExtensionContext, where: string): void {
349
+ function attemptAutoReload(ctx: ExtensionContext, where: string): boolean {
350
350
  try {
351
- if (loadSettings(ctx.cwd).autoReloadOnStale === false) return;
351
+ if (loadSettings(ctx.cwd).autoReloadOnStale === false) return false;
352
352
  const tmuxPane = process.env.TMUX_PANE;
353
353
  const wezPane = process.env.WEZTERM_PANE;
354
354
  let transport: "tmux" | "wezterm";
@@ -372,13 +372,58 @@ function attemptAutoReload(ctx: ExtensionContext, where: string): void {
372
372
  cmd = `wezterm cli send-text --pane-id ${pane} --no-paste '/reload\r'`;
373
373
  } else {
374
374
  appendLedger(ctx.cwd, "auto_reload_skipped", { where, reason: "no supported multiplexer (tmux/WezTerm) in env" });
375
- return;
375
+ return false;
376
376
  }
377
377
  appendLedger(ctx.cwd, "auto_reload_injected", { where, transport, pane });
378
378
  ctx.ui.notify("glla: injecting /reload into this pane — extensions rebuild in place and the fresh instance holds state. /glla resume after (automatic with autoresume=on).", "info");
379
379
  exec(cmd, () => { /* best-effort */ });
380
+ return true;
380
381
  } catch {
381
382
  /* never let recovery take the warning path down */
383
+ return false;
384
+ }
385
+ }
386
+
387
+ /** v0.34.13: one rung of the auto-recovery ladder. Returns true when a
388
+ * /reload was injected (caller should NOT also pause/alert the manual
389
+ * cure). Writes the sidecar resume marker FIRST so the fresh instance
390
+ * resumes the goal/loop even with autoresume=off. Throttled — a second
391
+ * wedge inside the window is the pi-restart class and must reach the
392
+ * user as a loud stop, not another reload. */
393
+ let lastAutoRecoveryAt = 0;
394
+ function attemptAutoRecovery(ctx: ExtensionContext, where: string): boolean {
395
+ if (loadSettings(ctx.cwd).autoRecovery === false) return false;
396
+ const now = Date.now();
397
+ if (now - lastAutoRecoveryAt < AUTO_RECOVERY_THROTTLE_MS) return false;
398
+ // Inject FIRST — the throttle stamp + marker must reflect a real
399
+ // recovery, not a no-transport skip (else the watchdog would mislabel
400
+ // the next wedge as the pi-restart class).
401
+ if (!attemptAutoReload(ctx, where)) return false;
402
+ lastAutoRecoveryAt = now;
403
+ try {
404
+ fs.writeFileSync(
405
+ path.join(piGlaDir(ctx.cwd), RECOVERY_RESUME_MARKER),
406
+ JSON.stringify({ at: new Date(now).toISOString(), where }),
407
+ );
408
+ } catch { /* best-effort — the reload still helps; restore just holds */ }
409
+ appendLedger(ctx.cwd, "auto_recovery_reload", { where });
410
+ ctx.ui.notify(`glla auto-recovery: ${where} — the ${isLoopActive() ? "loop" : "goal/list item"} resumes ITSELF after the rebuild (no /glla resume needed; the sidecar marker carries the consent). If this recurs within ${Math.round(AUTO_RECOVERY_THROTTLE_MS / 60_000)}m it's the pi-restart class and you'll get a loud stop.`, "warning");
411
+ return true;
412
+ }
413
+
414
+ /** v0.34.13: consume the sidecar marker on session restore. Single-use,
415
+ * freshness-bounded — a stale marker from an abandoned recovery must not
416
+ * surprise-resume a later session. */
417
+ function consumeRecoveryResume(cwd: string): boolean {
418
+ try {
419
+ const p = path.join(piGlaDir(cwd), RECOVERY_RESUME_MARKER);
420
+ if (!fs.existsSync(p)) return false;
421
+ const raw = fs.readFileSync(p, "utf-8");
422
+ fs.unlinkSync(p);
423
+ const at = Date.parse((JSON.parse(raw) as { at?: string }).at ?? "");
424
+ return !Number.isNaN(at) && Date.now() - at < RECOVERY_RESUME_FRESH_MS;
425
+ } catch {
426
+ return false;
382
427
  }
383
428
  }
384
429
 
@@ -641,6 +686,25 @@ const ZOMBIE_RUN_ALERT_THROTTLE_MS = 10 * 60_000;
641
686
  // last send; a landed turn — even a lazy text-only one — disarms it.
642
687
  const CONTINUATION_UNANSWERED_MS = 150_000;
643
688
  const CONTINUATION_UNANSWERED_THROTTLE_MS = 300_000;
689
+ // v0.34.12: eager-continuation settle delay. Hellhunter 2026-08-01 (post-
690
+ // restart): every turn cycle paid a 60s heartbeat tax because the eager
691
+ // continuation fires AT agent_end — exactly pi's turn-teardown blackhole
692
+ // window — so it vanished and the 60s heartbeat refire did the real work.
693
+ // Ledger showed the tell: goal_continuation_sent pairs + a refire per
694
+ // cycle. Sending 2.5s AFTER agent_end lets teardown settle; the send lands
695
+ // and the next turn starts immediately. 2.5s per turn beats 60s per turn.
696
+ const EAGER_CONTINUATION_SETTLE_MS = Number(process.env.GLLA_EAGER_SETTLE_MS ?? 2_500);
697
+ // v0.34.13: auto-recovery ladder ("keep going unless we MUST stop — a
698
+ // question, or done" — user directive 2026-08-01). A wedge that only a
699
+ // /reload cures should /reload ITSELF: inject the keystrokes (v0.29.13
700
+ // transport) with a sidecar marker so the fresh instance RESUMES even when
701
+ // autoresume=off (the consent came from autoRecovery at recovery time, not
702
+ // the restore-time setting). Throttled to one attempt per window: a
703
+ // recurrence inside the window is the transcript-writer-dead class, which
704
+ // only a pi restart cures — that one stays a loud stop for the human.
705
+ const AUTO_RECOVERY_THROTTLE_MS = 600_000;
706
+ const RECOVERY_RESUME_MARKER = "recovery-resume.json";
707
+ const RECOVERY_RESUME_FRESH_MS = 300_000;
644
708
  // v0.29.19: dead-turn caps (agent_end exemption path). 6 consecutive
645
709
  // provider-error turns = a real outage, not bad luck — stop honestly.
646
710
  // 3 consecutive user aborts = the user means it (user aborts mean STOP).
@@ -718,7 +782,9 @@ function startUITicker(): void {
718
782
  if (uiTicker) return;
719
783
  uiTicker = setInterval(() => {
720
784
  const ctx = freshCtx();
721
- if (ctx && isSupervising()) refreshUI(ctx);
785
+ // v0.34.12: keep ticking during a timed wait-pause too — the status
786
+ // line counts down to resumeAt live (pully field request 2026-08-01).
787
+ if (ctx && (isSupervising() || (state.goal?.status === "paused" && !!state.goal.pauseResumeAt))) refreshUI(ctx);
722
788
  }, 1_000);
723
789
  uiTicker.unref?.();
724
790
  }
@@ -815,6 +881,10 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
815
881
  const silent = Math.round(SEND_REARM_ESCALATE_SILENT_MS / 60000);
816
882
  appendLedger(ctx.cwd, "send_rearm_escalated", { kind, afterMinutes: mins, silentMinutes: silent });
817
883
  if (kind === "loop" && isLoopActive()) {
884
+ if (attemptAutoRecovery(ctx, "send-retry storm")) {
885
+ appendLedger(ctx.cwd, "send_rearm_escalated_suppressed", { reason: "auto-recovery reload" });
886
+ return;
887
+ }
818
888
  clearLoopTimer();
819
889
  state.loop = { ...state.loop!, active: false, stopReason: `send-retry storm: ${mins}m of re-arms with no session activity for ${silent}m — the session is wedged. Press Escape to cancel the stuck run (pi's own rate-limit retry holds it; pi prints "escape to cancel"), then /loop resume — the loop holds on restore. If still wedged: /reload rebuilds extensions in place, then /loop resume again. Restart pi only if /reload itself fails.` };
820
890
  persistState(ctx);
@@ -838,6 +908,14 @@ function escalateSendRearmStorm(ctx: ExtensionContext, kind: "continuation" | "l
838
908
  return;
839
909
  }
840
910
  if (state.goal && state.goal.status === "active") {
911
+ // v0.34.13: keep going unless we MUST stop — try the auto-recovery
912
+ // /reload before spending the user's attention on a pause. A reload
913
+ // that fails to cure throttles the next attempt, and the pause below
914
+ // fires then as today.
915
+ if (attemptAutoRecovery(ctx, "send-retry storm")) {
916
+ appendLedger(ctx.cwd, "send_rearm_escalated_suppressed", { reason: "auto-recovery reload" });
917
+ return;
918
+ }
841
919
  updateGoal({
842
920
  status: "paused",
843
921
  pauseKind: "error",
@@ -853,6 +931,12 @@ function escalateStallNow(ctx: ExtensionContext, threshold: number): boolean {
853
931
  if (!shouldEscalateStall(consecutiveStalls, threshold)) return false;
854
932
  consecutiveStalls = 0;
855
933
  appendLedger(ctx.cwd, "stall_escalated", { threshold, kind: isLoopActive() ? "loop" : "goal" });
934
+ // v0.34.13: recovery before stop — usually throttled (the 2.5min
935
+ // watchdog already tried), in which case the stop proceeds as today.
936
+ if (attemptAutoRecovery(ctx, "stall escalation")) {
937
+ appendLedger(ctx.cwd, "stall_escalated_suppressed", { reason: "auto-recovery reload", threshold });
938
+ return true;
939
+ }
856
940
  if (isLoopActive()) {
857
941
  clearLoopTimer();
858
942
  state.loop = { ...state.loop!, active: false, stopReason: `stalled: ${threshold} continuation refires landed no turn — the session is not continuing (wedged message queue or stale API). Press Escape to cancel any stuck run, then /loop resume — the loop holds on restore. If the handle is stale, /reload rebuilds extensions in place; restart pi only if /reload fails.` };
@@ -962,9 +1046,17 @@ function heartbeatTick(): void {
962
1046
  ) {
963
1047
  lastUnansweredAlertAt = Date.now();
964
1048
  appendLedger(ctx.cwd, "continuation_unanswered", { silentMs: Date.now() - lastContinuationSentAt });
965
- const msg = `glla: pi accepted the continuation ${Math.round((Date.now() - lastContinuationSentAt) / 60_000)}m ago but NO turn has started — no tool calls, no tokens, transcript frozen (the turn trigger is wedged; same pi failure family as the post-compaction blackhole). Re-sends don't unstick it. Cure: /reload — autoresume re-fires the ${isLoopActive() ? "loop" : "goal/list item"} automatically.`;
966
- ctx.ui.notify(msg, "warning");
967
- notifyExternal(ctx, msg);
1049
+ // v0.34.13: recover, don't just report. Only the failure modes reach
1050
+ // the user: throttled (a reload already failed to cure → the
1051
+ // pi-restart class) or recovery unavailable (setting off / no pane).
1052
+ if (!attemptAutoRecovery(ctx, "continuation unanswered")) {
1053
+ const mins = Math.round((Date.now() - lastContinuationSentAt) / 60_000);
1054
+ const msg = lastAutoRecoveryAt > 0
1055
+ ? `glla: auto-recovery /reload did NOT unstick this session — still no turn ${mins}m after the continuation. This class kills pi's transcript writer; only a pi RESTART cures it: restart pi in this tab, then /glla resume (the goal holds in .pi-glla state).`
1056
+ : `glla: pi accepted the continuation ${mins}m ago but NO turn has started — no tool calls, no tokens, transcript frozen (the turn trigger is wedged). Cure: /reload — autoresume re-fires the ${isLoopActive() ? "loop" : "goal/list item"}. (auto-recovery is off or no tmux/WezTerm pane — /glla settings autoRecovery=on enables the automatic form.)`;
1057
+ ctx.ui.notify(msg, "warning");
1058
+ notifyExternal(ctx, msg);
1059
+ }
968
1060
  }
969
1061
  // v0.29.1: stranded-audit recovery. A goal left in "auditing" with NO
970
1062
  // in-flight audit means the auditor's result never landed (wedged queue
@@ -6323,9 +6415,12 @@ export default function (pi: ExtensionAPI): void {
6323
6415
  persistState(ctx);
6324
6416
  appendLedger(ctx.cwd, "audit_loop_target_migrated", { from: "audit-every-iteration", to: "fix-first" });
6325
6417
  }
6418
+ // v0.34.13: an auto-recovery /reload carries its own resume consent —
6419
+ // the sidecar marker overrides autoresume=off for THIS restore only.
6420
+ const recoveryResume = consumeRecoveryResume(ctx.cwd);
6326
6421
  if (isLoopActive()) {
6327
6422
  const l = state.loop!;
6328
- if (autoResume) {
6423
+ if (autoResume || recoveryResume) {
6329
6424
  ctx.ui.notify(
6330
6425
  `Resuming loop (iteration ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"}, best ${l.bestValue ?? "n/a"}, stall ${l.stallCount}/${l.plateauWindow}): ${l.target.slice(0, 60)}`,
6331
6426
  "info",
@@ -6346,7 +6441,7 @@ export default function (pi: ExtensionAPI): void {
6346
6441
  // "load it but not auto start it"). Interrupted goals hold like
6347
6442
  // everything else; autoresume=on (unattended rigs) still auto-resumes
6348
6443
  // them, and the marker is cleared only on that promised auto-resume.
6349
- if (autoResume) {
6444
+ if (autoResume || recoveryResume) {
6350
6445
  // v0.28.1 (S2): clear the stale-handle interrupt marker — this IS
6351
6446
  // the auto-resume the marker promised.
6352
6447
  if (wasInterrupted) updateGoal({ interruptedAt: undefined, interruptedReason: undefined }, ctx);
@@ -6711,7 +6806,8 @@ export default function (pi: ExtensionAPI): void {
6711
6806
  // pause/cancel, the stall watchdog, the 5-consecutive-errors pause, or
6712
6807
  // the token guard — never via an elapsed-time cutoff.
6713
6808
 
6714
- scheduleContinuation(ctx, false);
6809
+ // v0.34.12: NOT immediately — agent_end is the blackhole boundary.
6810
+ scheduleContinuation(ctx, false, EAGER_CONTINUATION_SETTLE_MS);
6715
6811
  });
6716
6812
 
6717
6813
  pi.on("tool_call", (event: any) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.34.11",
3
+ "version": "0.34.13",
4
4
  "description": "Mission control for autonomous pi: interview-drafted goals, an audited task queue, and forever-loops (metric, spec, project-audit) that run for hours. An isolated extension-less auditor re-verifies every completion with raw evidence; confirmed drafts, decision pauses and consent gates keep you in charge.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",