pi-goal-list-loop-audit 0.34.18 → 0.34.19

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.
@@ -310,17 +310,24 @@ function heldLoopLines(l: LoopState, now: number, theme?: DisplayTheme, width?:
310
310
  function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | undefined, now: number, theme?: DisplayTheme, width?: number, extras?: WidgetExtras): string[] {
311
311
  // Head glyph is ● (not ◆): U+25C6 renders as a color-emoji diamond in some
312
312
  // terminal fonts and ignores ANSI color; ● takes the paint everywhere.
313
+ const interrupted = g.status === "active" && !!g.interruptedAt;
313
314
  const icon =
314
- g.status === "paused"
315
- ? paint(theme, pauseIsError(g) ? "error" : "warning", "⏸")
316
- : g.status === "auditing"
317
- ? paint(theme, "accent", "⟡")
318
- : paint(theme, "success", "●");
315
+ interrupted
316
+ ? paint(theme, "error", "⚠")
317
+ : g.status === "paused"
318
+ ? paint(theme, pauseIsError(g) ? "error" : "warning", "⏸")
319
+ : g.status === "auditing"
320
+ ? paint(theme, "accent", "⟡")
321
+ : paint(theme, "success", "●");
319
322
  // v0.24.7: a list item is named as such and points at /list — before,
320
323
  // the widget called it "active" and hinted "/goal status", reading as if
321
324
  // queue work were a standalone goal.
322
325
  const isList = g.policy === "list";
323
- const statusWord = g.status === "active" ? paint(theme, "success", "active") : g.status;
326
+ const statusWord = interrupted
327
+ ? paint(theme, "error", "interrupted")
328
+ : g.status === "active"
329
+ ? paint(theme, "success", "active")
330
+ : g.status;
324
331
  // v0.33.0: slim card — status folds INTO the head line as middot segments
325
332
  // (filter(Boolean).join, the universal CLI idiom). Line 2 is the live
326
333
  // "last action · next task" line; the footer stays the hint line.
@@ -351,6 +358,12 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
351
358
  const objBudget = width && width > 0 ? Math.max(16, width - 1 - 2 - 3 - visibleLen(segsText)) : 48;
352
359
  const head = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
353
360
  const lines = [head];
361
+ if (interrupted) {
362
+ const resumeCmd = isList ? "/list resume" : "/goal resume";
363
+ lines.push(`├─ ${paint(theme, "error", "host session lost — waiting for fresh session_start")}`);
364
+ lines.push(`└─ ${paint(theme, "warning", `/reload to rebind · ${resumeCmd} if it does not resume`)}`);
365
+ return lines;
366
+ }
354
367
  if (g.status === "auditing") {
355
368
  lines.push(`├─ auditor: ${audit?.label ?? "running"}${audit?.currentTool ? ` · ${truncate(audit.currentTool, 30)}` : ""}`);
356
369
  // v0.25.4: auditor-quiet stall — progress events stopped arriving
@@ -35,6 +35,48 @@ export interface LengthContinueTick {
35
35
  consecutive: number;
36
36
  }
37
37
 
38
+ export interface ContextUsageLike {
39
+ tokens?: number | null;
40
+ contextWindow?: number;
41
+ percent?: number | null;
42
+ }
43
+
44
+ export interface AssistantLengthMessageLike {
45
+ stopReason?: string;
46
+ usage?: {
47
+ output?: number;
48
+ };
49
+ }
50
+
51
+ export const LENGTH_CONTINUE_CONTEXT_STARVED_PERCENT = 90;
52
+ export const LENGTH_CONTINUE_CONTEXT_STARVED_MAX_OUTPUT = 8;
53
+
54
+ /**
55
+ * v0.34.19: distinguish a REAL overlong assistant response from pi's
56
+ * context-safety clamp. Near the configured context ceiling,
57
+ * pi-ai's clampMaxTokensToContext() can reduce max_tokens to 1; MiniMax then
58
+ * returns stopReason "length" with ~1 output token. That is context
59
+ * starvation: auto-compaction must own recovery. Sending LENGTH_CONTINUE_TEXT
60
+ * here queues another 1-token request before pi's post-agent_end compaction
61
+ * check and delays the actual cure (field: darklord 2026-08-02, 198,116 /
62
+ * 198,179 total tokens of a 200,000 window, output=1 twice).
63
+ */
64
+ export function isContextStarvedLengthStop(
65
+ message: AssistantLengthMessageLike | null | undefined,
66
+ contextUsage: ContextUsageLike | null | undefined,
67
+ ): boolean {
68
+ if (message?.stopReason !== "length") return false;
69
+ const output = message.usage?.output;
70
+ if (typeof output !== "number" || !Number.isFinite(output)) return false;
71
+ if (output > LENGTH_CONTINUE_CONTEXT_STARVED_MAX_OUTPUT) return false;
72
+ const percent = typeof contextUsage?.percent === "number"
73
+ ? contextUsage.percent
74
+ : typeof contextUsage?.tokens === "number" && typeof contextUsage?.contextWindow === "number" && contextUsage.contextWindow > 0
75
+ ? (contextUsage.tokens / contextUsage.contextWindow) * 100
76
+ : null;
77
+ return percent !== null && Number.isFinite(percent) && percent >= LENGTH_CONTINUE_CONTEXT_STARVED_PERCENT;
78
+ }
79
+
38
80
  export function makeLengthContinueTracker(max: number = LENGTH_CONTINUE_MAX) {
39
81
  let consecutive = 0;
40
82
  let gaveUp = false;
@@ -92,6 +92,7 @@ import {
92
92
  import {
93
93
  LENGTH_CONTINUE_MAX,
94
94
  LENGTH_CONTINUE_TEXT,
95
+ isContextStarvedLengthStop,
95
96
  resetLengthContinue,
96
97
  tickLengthContinue,
97
98
  } from "../length-continue.js";
@@ -231,6 +232,11 @@ let extensionApiStale = false;
231
232
  // path must still ledger the stale handle, stop stale work, and preserve the
232
233
  // interrupt marker so a later fresh lifecycle can restore it.
233
234
  let staleTerminalDone = false;
235
+ // v0.34.19: delayed session-owned callbacks capture this generation. A
236
+ // clearTimeout can race a callback already queued by Node; without a
237
+ // generation check, an old compaction/refire callback can run after /reload
238
+ // and schedule work against the fresh session.
239
+ let sessionGeneration = 0;
234
240
 
235
241
  /** v0.26.7: a stale api is terminal for this process — go loudly with
236
242
  * restart guidance instead of retrying sends that can never land.
@@ -309,6 +315,10 @@ function goStaleTerminal(ctx: ExtensionContext, where: string): void {
309
315
  } else if (state.goal && state.goal.status === "active") {
310
316
  updateGoal({ interruptedAt: nowIso(), interruptedReason: `extension api stale (${where})` }, ctx);
311
317
  }
318
+ // The stale process loses its ticker immediately, so paint the durable
319
+ // interrupted state synchronously while the old UI handle can still accept
320
+ // updates. The next session_start paints it again from disk.
321
+ refreshUI(ctx);
312
322
  ctx.ui.notify(`glla: ${guidance}`, "warning");
313
323
  notifyExternal(ctx, `glla: extension api stale — waiting for a fresh session_start; restart pi normally only if no replacement arrives. (${where})`);
314
324
  }
@@ -622,6 +632,10 @@ function releaseInitialSessionLoadBarrier(): void {
622
632
  }
623
633
 
624
634
  function rememberCtx(ctx: ExtensionContext): void {
635
+ // Late events from a disposed session must never reclaim lastCtx after the
636
+ // lifecycle handoff has been declared. Only session_start clears these
637
+ // gates and may bind a fresh context.
638
+ if (sessionHandoffPending || staleTerminalDone || zombieStoodDown) return;
625
639
  let ownerLive = false;
626
640
  if (ownerSession && lastCtx) {
627
641
  try { lastCtx.isIdle(); ownerLive = true; } catch { /* owner went stale (session replaced) */ }
@@ -1210,9 +1224,19 @@ function clearContinuationTimer(): void {
1210
1224
  }
1211
1225
 
1212
1226
  function scheduleSessionTimeout(callback: () => void, delayMs: number): NodeJS.Timeout {
1227
+ const generation = sessionGeneration;
1213
1228
  let timer: NodeJS.Timeout;
1214
1229
  timer = setTimeout(() => {
1215
1230
  sessionTimeouts.delete(timer);
1231
+ // clearTimeout is not enough when the callback is already queued. Do not
1232
+ // let an old session's callback re-arm work after stale/shutdown/reload.
1233
+ if (
1234
+ generation !== sessionGeneration ||
1235
+ sessionHandoffPending ||
1236
+ extensionApiStale ||
1237
+ staleTerminalDone ||
1238
+ zombieStoodDown
1239
+ ) return;
1216
1240
  callback();
1217
1241
  }, delayMs);
1218
1242
  sessionTimeouts.add(timer);
@@ -1222,6 +1246,7 @@ function scheduleSessionTimeout(callback: () => void, delayMs: number): NodeJS.T
1222
1246
 
1223
1247
  function clearSessionOwnedTimers(): void {
1224
1248
  sessionHandoffPending = true;
1249
+ sessionGeneration++;
1225
1250
  initialSessionLoadPending = false;
1226
1251
  clearContinuationTimer();
1227
1252
  clearLoopTimer();
@@ -1285,7 +1310,7 @@ function armQueueStuckProbe(sentAt: number): void {
1285
1310
  }
1286
1311
 
1287
1312
  function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: number): void {
1288
- if (sessionHandoffPending || initialSessionLoadPending) return;
1313
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
1289
1314
  abortedStandDown = false; // v0.29.5: any explicit schedule ends the stand-down
1290
1315
  if (!isActionableGoal()) return;
1291
1316
  rememberCtx(ctx);
@@ -1303,7 +1328,7 @@ function scheduleContinuation(ctx: ExtensionContext, force = false, delayMs?: nu
1303
1328
  }
1304
1329
 
1305
1330
  function sendContinuation(goalId: string): void {
1306
- if (sessionHandoffPending || initialSessionLoadPending) return;
1331
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
1307
1332
  continuationTimer = null;
1308
1333
  continuationScheduledFor = null;
1309
1334
  if (!isActionableGoal()) return;
@@ -2910,7 +2935,7 @@ function loopPrompt(loop: LoopState, regressionNote: string, strategyNote: strin
2910
2935
  }
2911
2936
 
2912
2937
  function scheduleLoopTick(ctx: ExtensionContext): void {
2913
- if (sessionHandoffPending || initialSessionLoadPending || !isLoopActive()) return;
2938
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown || !isLoopActive()) return;
2914
2939
  rememberCtx(ctx);
2915
2940
  clearLoopTimer();
2916
2941
  let delay = 0;
@@ -2923,7 +2948,7 @@ function scheduleLoopTick(ctx: ExtensionContext): void {
2923
2948
  }
2924
2949
 
2925
2950
  function sendLoopTurn(): void {
2926
- if (sessionHandoffPending || initialSessionLoadPending) return;
2951
+ if (sessionHandoffPending || initialSessionLoadPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
2927
2952
  loopTimer = null;
2928
2953
  if (!isLoopActive() || !extensionApi) return;
2929
2954
  const ctx = freshCtx();
@@ -6234,6 +6259,9 @@ export default function (pi: ExtensionAPI): void {
6234
6259
  // 60s heartbeat notices. Re-arm it as soon as pi settles post-compact.
6235
6260
  pi.on("session_compact", async (_event: any, ctx: ExtensionContext) => {
6236
6261
  if (isForeignCtx(ctx)) return;
6262
+ // A late compact event can arrive after pi has already invalidated this
6263
+ // extension. It must not reclaim the old ctx or schedule settle refires.
6264
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
6237
6265
  rememberCtx(ctx);
6238
6266
  if (!isSupervising()) return;
6239
6267
  appendLedger(ctx.cwd, "session_compact", {});
@@ -6299,6 +6327,7 @@ export default function (pi: ExtensionAPI): void {
6299
6327
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
6300
6328
  // messages — count answered (non-cancelled) questionnaires as replies too.
6301
6329
  pi.on("tool_result", async (event: any) => {
6330
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
6302
6331
  noteToolResult(event); // v0.33.0: slim widget "last action" feed
6303
6332
  // v0.24.0: roll loop tool-result fingerprints (same-tool-same-result
6304
6333
  // detection) — recorded for ANY tool result while a loop is active.
@@ -6371,6 +6400,16 @@ export default function (pi: ExtensionAPI): void {
6371
6400
  if (isForeignCtx(ctx)) return;
6372
6401
  extensionApi = pi;
6373
6402
  sessionHandoffPending = false;
6403
+ // Reset terminal ownership before rememberCtx: this is the only event
6404
+ // allowed to bind a context after a stale/shutdown handoff.
6405
+ staleTerminalDone = false; // v0.33.1: a rebound session can go terminal again
6406
+ zombieStoodDown = false;
6407
+ sessionGeneration++;
6408
+ // Ephemeral watchdog counters belong to the old session, not the
6409
+ // persisted goal. Reset them so a stale boundary cannot make the next
6410
+ // fresh session inherit a false stall count.
6411
+ heartbeatNudges = 0;
6412
+ consecutiveStalls = 0;
6374
6413
  const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
6375
6414
  initialSessionLoadPending = isBlankInitialStartup(ctx, startReason);
6376
6415
  rememberCtx(ctx);
@@ -6384,8 +6423,6 @@ export default function (pi: ExtensionAPI): void {
6384
6423
  // confirm the new handle actually works.
6385
6424
  writeOwnerFile(ctx.cwd);
6386
6425
  sessionReplacementUntil = 0;
6387
- zombieStoodDown = false;
6388
- staleTerminalDone = false; // v0.33.1: a rebound session must be able to go terminal AGAIN (was: one-shot for the process lifetime)
6389
6426
  postCompactResumeOwed = false; // v0.33.1: a compact from a previous session must not resync THIS one
6390
6427
  postCompactResyncPending = false;
6391
6428
  appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
@@ -6605,6 +6642,10 @@ export default function (pi: ExtensionAPI): void {
6605
6642
  });
6606
6643
 
6607
6644
  pi.on("agent_end", async (event: any, ctx: ExtensionContext) => {
6645
+ // A late agent_end from the disposed session is not a fresh turn. Do not
6646
+ // account it, run length continuation, or schedule another send after a
6647
+ // stale terminal/handoff.
6648
+ if (sessionHandoffPending || extensionApiStale || staleTerminalDone || zombieStoodDown) return;
6608
6649
  rememberCtx(ctx);
6609
6650
  // v0.23.8: a subagent finishing must not drive the main session's
6610
6651
  // continuation loop.
@@ -6625,7 +6666,30 @@ export default function (pi: ExtensionAPI): void {
6625
6666
  const rawPriorA = assistants.length >= 2 ? assistants[assistants.length - 2] : null;
6626
6667
  const extractText = (m: any): string => (m && Array.isArray(m.content)) ? m.content.filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n") : "";
6627
6668
  const lastA = rawLastA ? { stopReason: rawLastA.stopReason, text: extractText(rawLastA), priorText: extractText(rawPriorA) } : null;
6628
- const lc = tickLengthContinue(lastA?.stopReason === "length");
6669
+ // v0.34.19: pi-ai clamps max_tokens to the remaining context before the
6670
+ // provider call. At ~99% context that clamp can be 1 token, which the
6671
+ // provider reports as stopReason "length" — but this is NOT an overlong
6672
+ // assistant response. Extension agent_end runs BEFORE pi's own
6673
+ // auto-compaction check (agent-session.js _handlePostAgentRun), so sending
6674
+ // LENGTH_CONTINUE_TEXT here queues another 1-token request and delays the
6675
+ // real cure. Defer to pi compaction; session_compact's resume debt owns
6676
+ // the next continuation. Older pi/test doubles without getContextUsage()
6677
+ // fail open to the legacy true-length path.
6678
+ const contextUsage = (() => {
6679
+ try { return typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined; } catch { return undefined; }
6680
+ })();
6681
+ const contextStarvedLength = isContextStarvedLengthStop(rawLastA, contextUsage);
6682
+ const lc = tickLengthContinue(lastA?.stopReason === "length" && !contextStarvedLength);
6683
+ if (contextStarvedLength) {
6684
+ appendLedger(ctx.cwd, "length_continue_deferred_context_full", {
6685
+ outputTokens: rawLastA?.usage?.output,
6686
+ contextTokens: contextUsage?.tokens ?? null,
6687
+ contextWindow: contextUsage?.contextWindow ?? null,
6688
+ contextPercent: contextUsage?.percent ?? null,
6689
+ });
6690
+ ctx.ui.notify("glla: output-token stop was context starvation (tiny output at a nearly full context) — yielding to pi auto-compaction instead of re-sending.", "info");
6691
+ return;
6692
+ }
6629
6693
  if (lc.giveUpNow) {
6630
6694
  ctx.ui.notify(`glla: response hit the output-token cap ${LENGTH_CONTINUE_MAX}× in a row — stepping aside. Ask the model to split the work into smaller pieces.`, "warning");
6631
6695
  notifyExternal(ctx, "Response truncated 3× in a row — giving up auto-continue.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.34.18",
3
+ "version": "0.34.19",
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",