pi-goal-list-loop-audit 0.32.1 → 0.33.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.
@@ -338,6 +338,7 @@ export async function runGoalCompletionAuditor(args: {
338
338
  } finally {
339
339
  clearInterval(stallTimer);
340
340
  unsub();
341
+ args.signal?.removeEventListener("abort", abort); // v0.33.1: don't retain the session via the signal
341
342
  // v0.32.0: dispose the auditor session — each complete_goal leaked one
342
343
  // session's subscriptions/stream resources for the parent's lifetime.
343
344
  (session as any).dispose?.();
@@ -48,6 +48,37 @@ export function truncate(s: string, max: number): string {
48
48
  return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
49
49
  }
50
50
 
51
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
52
+ /** v0.33.1: painted strings measure by their VISIBLE width. */
53
+ function visibleLen(s: string): number {
54
+ return s.replace(ANSI_RE, "").length;
55
+ }
56
+
57
+ /** v0.33.0: 5-cell meter with a rounding guard (command-code's rule — never
58
+ * shows empty or full unless the value truly is 0 or 1). */
59
+ export function meter(frac: number, cells = 5): string {
60
+ if (!Number.isFinite(frac) || frac <= 0) return "▱".repeat(cells);
61
+ if (frac >= 1) return "▰".repeat(cells);
62
+ let filled = Math.round(frac * cells);
63
+ if (filled === 0) filled = 1;
64
+ if (filled === cells) filled = cells - 1;
65
+ return "▰".repeat(filled) + "▱".repeat(cells - filled);
66
+ }
67
+
68
+ /** v0.33.0: one finished tool call, for the slim card's "last action" line. */
69
+ export interface RecentActionDisplay {
70
+ name: string;
71
+ arg?: string;
72
+ ms: number;
73
+ ok: boolean;
74
+ }
75
+
76
+ /** v0.33.0: widget extras — the refire streak plus the recent-action feed. */
77
+ export interface WidgetExtras {
78
+ stalls?: number;
79
+ recent?: RecentActionDisplay[];
80
+ }
81
+
51
82
  /**
52
83
  * Word-wrap to `width`, capped at `maxLines` (v0.27.1). A pause is the one
53
84
  * state where the FULL text matters — the reason often carries a decision
@@ -134,7 +165,7 @@ export interface AuditDisplayProgress {
134
165
  * One-line status for ctx.ui.setStatus("pi-glla", …).
135
166
  * Returns undefined when nothing is being supervised (clears the segment).
136
167
  */
137
- export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, extras?: { stalls?: number }): string | undefined {
168
+ export function buildStatusText(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, extras?: WidgetExtras): string | undefined {
138
169
  if (state.loop?.active) {
139
170
  const l = state.loop;
140
171
  // v0.26.1: surface the refire streak — a spinning supervisor is the
@@ -223,7 +254,7 @@ function countTotal(g: Goal): number {
223
254
  * Widget lines for ctx.ui.setWidget("pi-glla", lines).
224
255
  * Returns undefined when nothing is worth showing.
225
256
  */
226
- export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
257
+ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: WidgetExtras): string[] | undefined {
227
258
  const inner = buildWidgetLinesInner(state, audit, now, theme, width, extras);
228
259
  // v0.28.6 (E1): a persistence failure outranks everything — first line,
229
260
  // on every render, until a write lands again.
@@ -234,7 +265,7 @@ export function buildWidgetLines(state: State, audit?: AuditDisplayProgress | nu
234
265
  return inner;
235
266
  }
236
267
 
237
- function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] | undefined {
268
+ function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null, now = Date.now(), theme?: DisplayTheme, width?: number, extras?: WidgetExtras): string[] | undefined {
238
269
  if (state.loop?.active) return loopLines(state.loop, now, theme, width, extras);
239
270
  const g = state.goal;
240
271
  const held = heldLoop(state);
@@ -242,7 +273,7 @@ function buildWidgetLinesInner(state: State, audit?: AuditDisplayProgress | null
242
273
  // v0.28.17: no visible goal — the held loop gets its own card.
243
274
  return held ? heldLoopLines(held, now, theme, width) : undefined;
244
275
  }
245
- const lines = goalLines(g, state, audit, now, theme, width);
276
+ const lines = goalLines(g, state, audit, now, theme, width, extras);
246
277
  // v0.28.17: a held loop rides the goal card as a trailing line.
247
278
  if (held) {
248
279
  lines.push(`${paint(theme, "warning", "⏸")} ${truncate(held.target, budgetFor(width, 3, 64))}`);
@@ -262,7 +293,7 @@ function heldLoopLines(l: LoopState, now: number, theme?: DisplayTheme, width?:
262
293
 
263
294
  // Branch lines sit flush-left (pi-tasks convention): pi's widget renderer
264
295
  // adds its own one-space gutter, so any indent here doubles up.
265
- function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | undefined, now: number, theme?: DisplayTheme, width?: number): string[] {
296
+ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | undefined, now: number, theme?: DisplayTheme, width?: number, extras?: WidgetExtras): string[] {
266
297
  // Head glyph is ● (not ◆): U+25C6 renders as a color-emoji diamond in some
267
298
  // terminal fonts and ignores ANSI color; ● takes the paint everywhere.
268
299
  const icon =
@@ -271,22 +302,41 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
271
302
  : g.status === "auditing"
272
303
  ? paint(theme, "accent", "⟡")
273
304
  : paint(theme, "success", "●");
274
- const head = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), budgetFor(width, 3, 64))}`;
275
305
  // v0.24.7: a list item is named as such and points at /list — before,
276
306
  // the widget called it "active" and hinted "/goal status", reading as if
277
307
  // queue work were a standalone goal.
278
308
  const isList = g.policy === "list";
279
309
  const statusWord = g.status === "active" ? paint(theme, "success", "active") : g.status;
280
- // v0.28.30: the status line ALWAYS names the type (user note: "I don't
281
- // always see the type — I'd need to scroll up to see if goal/list/loop").
282
- // Before, only list items were named; a plain goal's card said "paused ·
283
- // 3m" with no type word. The loop surface has its own card.
284
- const typeWord = isList ? "list item · " : "goal · ";
310
+ // v0.33.0: slim card — status folds INTO the head line as middot segments
311
+ // (filter(Boolean).join, the universal CLI idiom). Line 2 is the live
312
+ // "last action · next task" line; the footer stays the hint line.
313
+ // v0.28.30: the type stays visible — v0.33.0 names it via the "list item"
314
+ // header segment (list policy) and the distinct card icons (● goal,
315
+ // ∞/↓/↑ loop, ⟡ auditing, ⏸ paused) + the type-named footer verbs.
285
316
  // Token segment only when a budget is set (v0.22.0): the guard is opt-in,
286
317
  // and "0/0 tok" carried no information when off.
287
318
  const tokenLimit = g.usage?.tokensLimit ?? 0;
288
- const tokens = tokenLimit > 0 ? ` · ${paint(theme, "dim", `${fmtTokens(g.usage?.tokensUsed ?? 0)}/${fmtTokens(tokenLimit)} tok`)}` : "";
289
- const lines = [head, `├─ ${typeWord}${statusWord} · ${fmtElapsed(now - Date.parse(g.createdAt))}${tokens}`];
319
+ const headSegs: string[] = [];
320
+ if (isList) headSegs.push("list item");
321
+ headSegs.push(statusWord);
322
+ headSegs.push(fmtElapsed(now - Date.parse(g.createdAt)));
323
+ const taskTotal = countTotal(g);
324
+ if (taskTotal > 0) headSegs.push(`${countDone(g)}/${taskTotal} ${paint(theme, "dim", meter(countDone(g) / taskTotal))}`);
325
+ const tokUsed0 = g.usage?.tokensUsed ?? 0;
326
+ if (tokenLimit > 0) headSegs.push(paint(theme, "dim", `${fmtTokens(tokUsed0)}/${fmtTokens(tokenLimit)} ${meter(tokUsed0 / tokenLimit)}`));
327
+ else if (tokUsed0 > 0) headSegs.push(paint(theme, "dim", `${fmtTokens(tokUsed0)} tok`));
328
+ // v0.28.30: the type stays visible — v0.33.0 names it via the "list item"
329
+ // header segment (list policy) and the distinct card icons (● goal,
330
+ // ∞/↓/↑ loop, ⟡ auditing, ⏸ paused) + the type-named footer verbs.
331
+ // Token segment only when a budget is set (v0.22.0): the guard is opt-in,
332
+ // and "0/0 tok" carried no information when off.
333
+ // v0.33.1: the head must FIT the terminal — the segments are fixed, so
334
+ // the objective absorbs whatever room is left (was: objective budgeted
335
+ // alone, segments appended unbudgeted → 140-col heads at width 100).
336
+ const segsText = headSegs.join(` ${paint(theme, "dim", "·")} `);
337
+ const objBudget = width && width > 0 ? Math.max(16, width - 1 - 2 - 3 - visibleLen(segsText)) : 48;
338
+ const head = `${icon} ${truncate(g.objective.replace(/\s+/g, " "), objBudget)} ${paint(theme, "dim", "·")} ${segsText}`;
339
+ const lines = [head];
290
340
  if (g.status === "auditing") {
291
341
  lines.push(`├─ auditor: ${audit?.label ?? "running"}${audit?.currentTool ? ` · ${truncate(audit.currentTool, 30)}` : ""}`);
292
342
  // v0.25.4: auditor-quiet stall — progress events stopped arriving
@@ -357,8 +407,16 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
357
407
  }
358
408
  return lines;
359
409
  }
410
+ // v0.33.0: "last action · next task" — Claude's done-row format meets the
411
+ // pending queue. Segments join with a dim middot; missing ones drop out.
412
+ const act = extras?.recent?.[extras.recent.length - 1];
413
+ const mid: string[] = [];
414
+ if (act) {
415
+ mid.push(`${paint(theme, act.ok ? "success" : "error", act.ok ? "✓" : "✗")} ${act.name}${act.arg ? ` ${paint(theme, "dim", truncate(act.arg, 24))}` : ""}${act.ms > 0 ? ` ${paint(theme, "dim", `(${fmtElapsed(act.ms)})`)}` : ""}`);
416
+ }
360
417
  const next = nextPending(g);
361
- if (next) lines.push(`├─ next: ${truncate(next, budgetFor(width, 9, 56))}`);
418
+ if (next) mid.push(`next: ${truncate(next, budgetFor(width, 9, 40))}`);
419
+ if (mid.length > 0) lines.push(`├─ ${mid.join(` ${paint(theme, "dim", "·")} `)}`);
362
420
  const queue = state.list?.length ?? 0;
363
421
  const footer = isList
364
422
  ? `${queue > 0 ? `${queue} queued · ` : ""}/list · /glla`
@@ -367,34 +425,33 @@ function goalLines(g: Goal, state: State, audit: AuditDisplayProgress | null | u
367
425
  return lines;
368
426
  }
369
427
 
370
- function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number, extras?: { stalls?: number }): string[] {
371
- // v0.26.1: the refire streak, shown only while nonzero.
372
- const stallNote = (extras?.stalls ?? 0) > 0 ? ` · ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
373
- // v0.23.0: metricless spec loop — no arrow/best/stall, no plateau.
374
- if (!l.measureCmd) {
375
- const lines = [
376
- `${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
377
- `├─ loop ∞ iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations}` : ""} · ${fmtElapsed(now - Date.parse(l.startedAt))}${stallNote}`,
378
- `└─ ${paint(theme, "dim", "metricless — work the spec (no plateau)")}`,
379
- ];
380
- if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
381
- return lines;
428
+ function loopLines(l: LoopState, now: number, theme?: DisplayTheme, width?: number, extras?: WidgetExtras): string[] {
429
+ // v0.33.0: slim loop card — header icon names the kind (∞ metricless,
430
+ // ↓/↑ metric), all state folds into middot segments; line 2 is the live
431
+ // "last action" line; footer is hints. The old per-line "loop ∞ iter" /
432
+ // "best/last/stall" rows collapse into the header.
433
+ const stallNote = (extras?.stalls ?? 0) > 0 ? ` ${paint(theme, "dim", "·")} ${paint(theme, "warning", `stalls:${extras!.stalls}`)}` : "";
434
+ const icon = !l.measureCmd ? paint(theme, "accent", "∞") : paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
435
+ const segs: string[] = [];
436
+ segs.push(`iter ${l.iteration}${l.maxIterations > 0 ? `/${l.maxIterations} ${paint(theme, "dim", meter(l.iteration / l.maxIterations))}` : ""}`);
437
+ segs.push(fmtElapsed(now - Date.parse(l.startedAt)));
438
+ if (l.measureCmd) {
439
+ segs.push(`best ${paint(theme, "success", `${l.bestValue ?? "n/a"}`)}`);
440
+ segs.push(`last ${l.lastValue ?? "n/a"}`); // v0.33.1: a plateauing loop's current reading stays visible
441
+ const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
442
+ segs.push(l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText);
382
443
  }
383
- const arrow = paint(theme, "accent", l.direction === "min" ? "↓" : "↑");
384
- const best = paint(theme, "success", `${l.bestValue ?? "n/a"}`);
385
- const stallText = `stall ${l.stallCount}/${l.plateauWindow}`;
386
- const stall = l.stallCount >= l.plateauWindow - 1 ? paint(theme, "warning", stallText) : stallText;
387
- const lines = [
388
- `${paint(theme, "accent", "●")} ${truncate(l.target, budgetFor(width, 3, 64))}`,
389
- `├─ loop ${arrow} iter ${l.iteration}/${l.maxIterations > 0 ? l.maxIterations : "∞"} · ${fmtElapsed(now - Date.parse(l.startedAt))}`,
390
- `├─ best ${best} · last ${l.lastValue ?? "n/a"} · ${stall}`,
391
- // v0.29.15: the audit loop's measure is orchestrator-owned shell — the
392
- // raw grep reads like leaked internals ("that weird line"). Name what
393
- // it measures instead; user-authored measures still show raw.
394
- `└─ ${paint(theme, "dim", l.kind === "audit"
395
- ? "metric: closed findings ('- [x]' count)"
396
- : truncate(l.measureCmd, budgetFor(width, 3, 56)))}`,
397
- ];
444
+ const segsText = segs.join(` ${paint(theme, "dim", "·")} `);
445
+ const targetBudget = width && width > 0 ? Math.max(16, width - 1 - 2 - 3 - visibleLen(segsText) - visibleLen(stallNote)) : 44;
446
+ const lines = [`${icon} ${truncate(l.target, targetBudget)} ${paint(theme, "dim", "·")} ${segsText}${stallNote}`];
447
+ const act = extras?.recent?.[extras.recent.length - 1];
448
+ if (act) {
449
+ lines.push(`├─ ${paint(theme, act.ok ? "success" : "error", act.ok ? "✓" : "✗")} ${act.name}${act.arg ? ` ${paint(theme, "dim", truncate(act.arg, 24))}` : ""}${act.ms > 0 ? ` ${paint(theme, "dim", `(${fmtElapsed(act.ms)})`)}` : ""}`);
450
+ }
451
+ const footer = !l.measureCmd
452
+ ? "metricless (no plateau) · /loop stop · /loop polish"
453
+ : `${l.kind === "audit" ? "metric: closed findings" : truncate(l.measureCmd, budgetFor(width, 3, 30))} · /loop stop`;
454
+ lines.push(`└─ ${paint(theme, "dim", footer)}`);
398
455
  if (l.branchName) lines.push(`⎇ ${paint(theme, "muted", truncate(l.branchName, budgetFor(width, 3, 50)))}`);
399
456
  return lines;
400
457
  }
@@ -211,6 +211,7 @@ export const SETTINGS_KEYS: Array<keyof Settings> = [
211
211
  "stallSimilarityThreshold",
212
212
  "postaudit",
213
213
  "toolOverrides",
214
+ "reviewer", // v0.33.1: legacy alias — menu saves can write it; provenance must know it exists
214
215
  ];
215
216
 
216
217
  /** Where each effective setting comes from (for the /glla display). */
@@ -630,6 +630,38 @@ function isSupervising(): boolean {
630
630
  let latestAuditProgress: AuditDisplayProgress | null = null;
631
631
  let uiTicker: NodeJS.Timeout | null = null;
632
632
 
633
+ // v0.33.0: slim widget "last action" feed — a tiny ring of finished tool
634
+ // calls {name, arg, ms, ok} captured from the tool_call/tool_result stream.
635
+ // Display-only, never persisted; cleared implicitly as new actions land.
636
+ const recentActions: import("../goal-loop-display.js").RecentActionDisplay[] = [];
637
+ const inFlightToolCalls = new Map<string, { name: string; arg?: string; at: number }>();
638
+ function summarizeToolArg(name: string, input: any): string | undefined {
639
+ if (!input || typeof input !== "object") return undefined;
640
+ const v = input.file_path ?? input.path ?? input.command ?? input.pattern ?? input.query ?? input.url ?? input.title;
641
+ if (typeof v !== "string" || v.length === 0) return undefined;
642
+ // v0.33.1: strip control chars BEFORE truncating — a raw \n breaks the
643
+ // widget card into un-prefixed lines (footer spoof), a raw ESC corrupts
644
+ // the TUI. The objective path was already whitespace-collapsed; this
645
+ // path was the gap.
646
+ const base = (name === "bash" ? v : v.split("/").pop() || v).replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim();
647
+ return base.length <= 24 ? base : base.slice(0, 23) + "…";
648
+ }
649
+ function noteToolCall(event: any): void {
650
+ const name = String(event?.toolName ?? "?");
651
+ const id = String(event?.toolCallId ?? event?.id ?? `anon-${Date.now()}`);
652
+ if (inFlightToolCalls.size >= 20) inFlightToolCalls.delete(inFlightToolCalls.keys().next().value!); // v0.33.1: evict BEFORE the 21st
653
+ inFlightToolCalls.set(id, { name, arg: summarizeToolArg(name, event?.input ?? event?.args), at: Date.now() });
654
+ }
655
+ function noteToolResult(event: any): void {
656
+ const id = String(event?.toolCallId ?? event?.id ?? "");
657
+ const f = id ? inFlightToolCalls.get(id) : undefined;
658
+ if (id) inFlightToolCalls.delete(id);
659
+ const ok = !Boolean(event?.isError ?? event?.error);
660
+ const name = f?.name ?? String(event?.toolName ?? "?");
661
+ recentActions.push({ name, arg: f?.arg ?? summarizeToolArg(name, event?.input ?? event?.args), ms: f ? Date.now() - f.at : 0, ok });
662
+ if (recentActions.length > 3) recentActions.shift();
663
+ }
664
+
633
665
  function refreshUI(ctx: ExtensionContext): void {
634
666
  if (!ctx.hasUI) return;
635
667
  try {
@@ -638,7 +670,7 @@ function refreshUI(ctx: ExtensionContext): void {
638
670
  // uses the room instead of cutting at fixed ~60-char floors.
639
671
  const width = process.stdout.columns || 80;
640
672
  ctx.ui.setStatus("pi-glla", buildStatusText(state, latestAuditProgress, Date.now(), theme, { stalls: consecutiveStalls }));
641
- ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width, { stalls: consecutiveStalls }));
673
+ ctx.ui.setWidget("pi-glla", buildWidgetLines(state, latestAuditProgress, Date.now(), theme, width, { stalls: consecutiveStalls, recent: recentActions }));
642
674
  } catch {
643
675
  // stale ctx — next event refreshes
644
676
  }
@@ -837,6 +869,14 @@ function heartbeatTick(): void {
837
869
  if (!absorbStaleIfSuperseded(ctx)) goStaleTerminal(ctx, "heartbeat probe");
838
870
  return;
839
871
  }
872
+ // v0.33.1: nothing supervised → the compact debt/resync belong to a dead
873
+ // goal/loop. Discharge here so a later goal can't inherit a bogus RESYNC
874
+ // block or a spurious forced refire (the old in-guard `else` was
875
+ // unreachable — isSupervising() ≡ isLoopActive() || isActionableGoal()).
876
+ if (!isSupervising() && (postCompactResumeOwed || postCompactResyncPending)) {
877
+ postCompactResumeOwed = false;
878
+ postCompactResyncPending = false;
879
+ }
840
880
  // v0.32.1: post-compaction resume debt — retry on every heartbeat tick
841
881
  // past grace until a turn actually starts. Fixed-offset settles alone
842
882
  // can both lose (pi busy at 2s AND at grace+2s = a dangling chain).
@@ -1076,7 +1116,10 @@ function sendContinuation(goalId: string): void {
1076
1116
  }
1077
1117
  if (!extensionApi || extensionApiStale) return;
1078
1118
  try {
1079
- const resync = postCompactResyncPending ? buildPostCompactResync() : "";
1119
+ let resync = "";
1120
+ // v0.33.1: a builder throw (corrupt restored state) must not masquerade
1121
+ // as a transport failure — send without the block instead.
1122
+ if (postCompactResyncPending) { try { resync = buildPostCompactResync(); } catch { resync = ""; } }
1080
1123
  extensionApi.sendMessage({
1081
1124
  customType: GOAL_EVENT_ENTRY,
1082
1125
  content: resync + continuationPrompt(state.goal!),
@@ -1251,6 +1294,14 @@ function notifyPersistenceState(ctx: ExtensionContext): void {
1251
1294
  }
1252
1295
 
1253
1296
  function setGoal(goal: Goal, ctx: ExtensionContext, via = "user"): void {
1297
+ // v0.33.1: per-goal module state resets at activation — a new goal must
1298
+ // not inherit the previous goal's compact debt/resync, quota streak,
1299
+ // token-message dedupe set, or widget action feed.
1300
+ postCompactResumeOwed = false;
1301
+ postCompactResyncPending = false;
1302
+ quotaRetryStreak = 0;
1303
+ countedTokenMessages.clear();
1304
+ recentActions.length = 0;
1254
1305
  // v0.28.14: never silently orphan a live goal — a paused/active goal
1255
1306
  // being replaced is archived honestly first (the old behavior left it in
1256
1307
  // goals/ but untracked: "older goals lying around leading to confusion").
@@ -1369,6 +1420,8 @@ async function fanOutListAuditFindings(ctx: ExtensionContext): Promise<void> {
1369
1420
  }
1370
1421
 
1371
1422
  function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?: string): void {
1423
+ postCompactResumeOwed = false; // v0.33.1: the dead goal's compact debt/resync dies with it
1424
+ postCompactResyncPending = false;
1372
1425
  if (!state.goal) return;
1373
1426
  const goal = state.goal;
1374
1427
  ensureDirs(ctx.cwd);
@@ -2610,7 +2663,13 @@ function sendLoopTurn(): void {
2610
2663
  if (!isLoopActive() || !extensionApi) return;
2611
2664
  const ctx = freshCtx();
2612
2665
  if (!ctx || !ctx.isIdle() || ctx.hasPendingMessages()) {
2613
- if (ctx) accountSendRearm(ctx, "loop");
2666
+ if (!ctx) {
2667
+ // v0.33.1: mirror sendContinuation — probe the handle (terminal exit)
2668
+ // and advance the streak so the cadence backs off instead of spinning
2669
+ // a flat 50ms below every watchdog.
2670
+ if (probeExtensionApiStale()) return;
2671
+ loopRearmStreak++;
2672
+ } else accountSendRearm(ctx, "loop");
2614
2673
  loopTimer = setTimeout(() => sendLoopTurn(), sendRearmDelayMs(loopRearmStreak)); // v0.28.29: backing-off cadence
2615
2674
  loopTimer.unref?.();
2616
2675
  return;
@@ -2666,7 +2725,8 @@ function sendLoopTurn(): void {
2666
2725
  // instruction (metricless loops; metric loops already vary via values).
2667
2726
  const variantNote = metricless ? continueVariant(loop.iteration) : "";
2668
2727
  try {
2669
- const loopResync = postCompactResyncPending ? buildPostCompactResync() : "";
2728
+ let loopResync = "";
2729
+ if (postCompactResyncPending) { try { loopResync = buildPostCompactResync(); } catch { loopResync = ""; } } // v0.33.1
2670
2730
  extensionApi.sendMessage({
2671
2731
  customType: GOAL_EVENT_ENTRY,
2672
2732
  content: loopResync + loopPrompt(loop, regressionNote, strategyNote, boundsNote, interventionNote, variantNote),
@@ -5817,6 +5877,7 @@ export default function (pi: ExtensionAPI): void {
5817
5877
  // v0.15.1: ask_user_question answers arrive as tool results, not chat
5818
5878
  // messages — count answered (non-cancelled) questionnaires as replies too.
5819
5879
  pi.on("tool_result", async (event: any) => {
5880
+ noteToolResult(event); // v0.33.0: slim widget "last action" feed
5820
5881
  // v0.24.0: roll loop tool-result fingerprints (same-tool-same-result
5821
5882
  // detection) — recorded for ANY tool result while a loop is active.
5822
5883
  if (isLoopActive()) {
@@ -5891,6 +5952,9 @@ export default function (pi: ExtensionAPI): void {
5891
5952
  writeOwnerFile(ctx.cwd);
5892
5953
  sessionReplacementUntil = 0;
5893
5954
  zombieStoodDown = false;
5955
+ staleTerminalDone = false; // v0.33.1: a rebound session must be able to go terminal AGAIN (was: one-shot for the process lifetime)
5956
+ postCompactResumeOwed = false; // v0.33.1: a compact from a previous session must not resync THIS one
5957
+ postCompactResyncPending = false;
5894
5958
  const startReason = typeof event?.reason === "string" ? event.reason : "unknown";
5895
5959
  appendLedger(ctx.cwd, "session_rebound", { reason: startReason });
5896
5960
  if (extensionApiStale) {
@@ -6390,10 +6454,11 @@ export default function (pi: ExtensionAPI): void {
6390
6454
  scheduleContinuation(ctx, false);
6391
6455
  });
6392
6456
 
6393
- pi.on("tool_call", () => {
6457
+ pi.on("tool_call", (event: any) => {
6394
6458
  toolCallsThisTurn++;
6395
6459
  noteActivity(true);
6396
6460
  lastStreamActivityAt = Date.now();
6461
+ noteToolCall(event); // v0.33.0
6397
6462
  // v0.24.0: count loop-iteration tool calls (narration-only detection).
6398
6463
  if (isLoopActive()) {
6399
6464
  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.32.1",
3
+ "version": "0.33.1",
4
4
  "description": "Goal. Loop. Audit. Done. \u2014 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 \u2014 only the read tools needed to verify your goal.",
5
5
  "license": "MIT",
6
6
  "author": "dracon",