pi-goal-list-loop-audit 0.38.92 → 0.38.95

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.95 — Stop the pause cycles: image-count clamp, ledger dedupe (2026-09-23)
4
+
5
+ - The payload guard now clamps image blocks per request (default 4, the observed provider limit) as well as bytes — small screenshots fit the byte budget but 400'd on count (field 2026-09-21: `Image count 12 exceeds limit 4`). Oldest-first, keep-recent floor wins on conflict; eviction entries carry the remaining count (audit/PAUSES-2026-09-22.md).
6
+ - Identical `payload_guard_eviction` repeats are deduped per generation (719 identical entries/day in the field) and zombie stand-down logging is latched per silent episode like the abort key (219/day) — first occurrence lands, repeats are silent, changed values re-arm.
7
+ - NOT in this release: a "quiet wait" for single-model chains was implemented then reverted — the cycle-reset probe IS the retry mechanism, and parking it wedged recovery into a never-retry loop (blocked-pause-autoclear caught it). Single-model retries are correct as-is.
8
+
9
+ ## 0.38.94 — Fix continuation spam: delta-only actually fires (2026-09-22)
10
+
11
+ - Field: 909 identical 28KB continuation prompts in one 8h session (57MB transcript). `firstSend` was computed from the in-flight dispatch latch, which is zero before every fresh send — so every send was "first" and the v0.38.5 marker path never fired. A dedicated per-goal latch now flips only when a full brief dispatches and resets on session rebind: one 28KB brief, then 45-char markers (audit/CONTINUATION-SPAM-2026-09-22.md).
12
+
13
+ ## 0.38.93 — Recovery monitoring: attempts, failing-since, cause (2026-09-22)
14
+
15
+ - The shared recovery status block (widget, `/goal status`) now carries trajectory facts: `Attempts: N · failing Xh` plus a `Cause: deterministic client error` line when classified — a held 400 with a bare countdown read like a transient that would clear. Transients stay unlabeled; the classifier moved to quota-retry.ts so display shares it without an import cycle.
16
+
3
17
  ## 0.38.92 — Deterministic 400s hold; dethroned sessions named (2026-09-22)
4
18
 
5
19
  - Main-model recovery no longer blind-retries deterministic provider refusals (HTTP 400 — e.g. 12 images against a 4-image model cap, which burned 7.5h of "retrying automatically"). Identical retries cannot succeed, so it holds for manual resume with fix directions (switch model or trim the request) in every mode; model switches retire the old episode's diagnostic so failover still probes (audit/DETERMINISTIC-400-AND-DETHRONED-2026-09-22.md).
package/docs/INDEX.md CHANGED
@@ -16,7 +16,7 @@ Policy contracts and recent changes live in the `audit/` directory of the
16
16
  failback; v0.35.9 hardened cross-version npm tarball checks; v0.35.10
17
17
  handles multi-entry npm dry-run reports; v0.35.11 accepts both npm report
18
18
  shapes; v0.35.12 supports npm 12's keyed pack reports; v0.35.13 fixes stale-API recovery loops.
19
- v0.35.14–v0.38.92 continue through the supervisor freeze (`/glla pause`),
19
+ v0.35.14–v0.38.95 continue through the supervisor freeze (`/glla pause`),
20
20
  load hold, auditor picker parity, Windows launch fix, zombie-watchdog
21
21
  subagent carve-out, due-wait backstop, the `/glla agents` visibility panel,
22
22
  durable state-root selection, blank-until-resume auditor context, frozen
@@ -260,6 +260,23 @@ let continuationScheduledFor: string | null = null;
260
260
 
261
261
  let lastContinuationSentAt = 0;
262
262
 
263
+ /** v0.38.94 (field 2026-09-22: 909 full 28KB sends in one session):
264
+ * goal id whose initial full brief already landed this session, or null.
265
+ * Previously `firstSend` was computed as `lastContinuationSentAt === 0`,
266
+ * but that latch tracks the IN-FLIGHT dispatch — it is zeroed on every
267
+ * ack/failure/timeout, i.e. before every fresh send — so EVERY send was
268
+ * "first" and the v0.38.5 delta-only path never fired. This latch flips
269
+ * only when a full payload actually dispatches, resets on session rebind
270
+ * (fresh context needs the brief again), and keys on goal id so a new
271
+ * goal re-teaches once. */
272
+ let continuationInitialFullSentFor: string | null = null;
273
+
274
+ /** Session-start rebind calls this: a fresh host context never saw the
275
+ * brief, so the next send for the restored goal goes full again. */
276
+ export function resetContinuationInitialSend(): void {
277
+ continuationInitialFullSentFor = null;
278
+ }
279
+
263
280
  // Audit 2026-09-07 (MEDIUM): wall-clock of the last genuine user-role
264
281
  // message observed via message_start. A user message arriving after a
265
282
  // dispatch was sent means the next agent_start/turn_start belongs to the
@@ -1376,7 +1393,7 @@ export function sendContinuation(goalId: string): void {
1376
1393
  // start-proof matching keeps working; fallback agent_start/turn_start
1377
1394
  // needs no prompt at all.
1378
1395
  const goalForSend = state.goal!;
1379
- const { content, kind } = buildContinuationContent(goalForSend, { resync, firstSend: lastContinuationSentAt === 0 });
1396
+ const { content, kind } = buildContinuationContent(goalForSend, { resync, firstSend: continuationInitialFullSentFor !== goalId });
1380
1397
  flags.extensionApi.sendMessage({
1381
1398
  customType: GOAL_EVENT_ENTRY,
1382
1399
  content,
@@ -1384,6 +1401,9 @@ export function sendContinuation(goalId: string): void {
1384
1401
  }, { triggerTurn: true, deliverAs: "followUp" });
1385
1402
  lastContinuationSentPayload = { content, display: false }; // v0.34.88: verbatim retry payload
1386
1403
  if (!dispatchAccepted(ctx, attempt)) return;
1404
+ // v0.38.94: flip the initial-send latch only once a full payload has
1405
+ // actually dispatched — a failed send must not burn the one full brief.
1406
+ if (kind === "full" || kind === "full+resync") continuationInitialFullSentFor = goalId;
1387
1407
  continuationRearmStreak = 0; continuationRearmSince = 0; // v0.28.5 (E3): an accepted dispatch clears the storm
1388
1408
  appendLedger(ctx.cwd, "goal_continuation_sent", { goalId, attemptId: attempt.id, generation: attempt.generation, kind, payloadChars: content.length, ...(busyBypass ? { busyBypass: true } : {}) });
1389
1409
  // v0.35.37 (audit finding): the welcome-back recovery notice must fire
@@ -178,6 +178,10 @@ let lastWedgeAlertAt = 0;
178
178
  // host turn exactly once. The key fences repeated heartbeat ticks and lets a
179
179
  // later explicit resume start a fresh, independently bounded attempt.
180
180
  let lastZombieAbortKey = "";
181
+ // Field 2026-09-22: the stand-down branch logged on EVERY heartbeat tick
182
+ // (219 zombie_run_stood_down_user_input in a day) while a wait was pending.
183
+ // Latch it per silent episode exactly like the abort key.
184
+ let lastZombieStandDownKey = "";
181
185
  let zombieRunSilentMsOverride: number | null = null;
182
186
  let zombieRunAbortGraceMsOverride: number | null = null;
183
187
 
@@ -328,6 +332,7 @@ export function __testOnlySetZombieRunWindows(silentMs: number | null, abortGrac
328
332
  zombieRunAbortGraceMsOverride = abortGraceMs;
329
333
  lastZombieAlertAt = 0;
330
334
  lastZombieAbortKey = "";
335
+ lastZombieStandDownKey = "";
331
336
  }
332
337
 
333
338
  /** Test-only reset for the process-global watchdog state. */
@@ -336,6 +341,7 @@ export function __testOnlyResetZombieRunWatchdog(): void {
336
341
  zombieRunAbortGraceMsOverride = null;
337
342
  lastZombieAlertAt = 0;
338
343
  lastZombieAbortKey = "";
344
+ lastZombieStandDownKey = "";
339
345
  }
340
346
 
341
347
  /** v0.35.17: release the one-shot abort latch when the bounded automatic
@@ -345,6 +351,7 @@ export function __testOnlyResetZombieRunWatchdog(): void {
345
351
  * NEVER re-abort — a permanently BUSY-silent session. */
346
352
  export function releaseZombieAbortKey(): void {
347
353
  lastZombieAbortKey = "";
354
+ lastZombieStandDownKey = "";
348
355
  }
349
356
 
350
357
  let lastUnansweredAlertAt = 0;
@@ -1392,7 +1399,9 @@ function heartbeatTick(): void {
1392
1399
  isUserInputWaitCall,
1393
1400
  );
1394
1401
  if (subagentWaitInFlight || userInputWaitInFlight) {
1395
- if (streamSilentMs >= zombieAbortMs && !flags.abortedStandDown) {
1402
+ const standDownKey = `${flags.sessionGeneration}:${state.goal?.id ?? "loop"}:${flags.lastStreamActivityAt}`;
1403
+ if (streamSilentMs >= zombieAbortMs && !flags.abortedStandDown && standDownKey !== lastZombieStandDownKey) {
1404
+ lastZombieStandDownKey = standDownKey;
1396
1405
  appendLedger(
1397
1406
  ctx.cwd,
1398
1407
  subagentWaitInFlight
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { createHash } from "node:crypto";
13
13
  import { execSync } from "node:child_process";
14
- import { normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, quotaSignal, sanitizeProviderAuditReport, sanitizeProviderDisplayText, type QuotaSignal } from "./quota-retry.js";
14
+ import { isDeterministicProviderError, normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, quotaSignal, sanitizeProviderAuditReport, sanitizeProviderDisplayText, type QuotaSignal } from "./quota-retry.js";
15
15
  import { MAX_AUDITOR_CANDIDATE_REFS, MAX_MAIN_MODEL_FALLBACKS, normalizeBoundedModelRefs } from "./main-model-recovery.js";
16
16
  import { resolveGllaStateDir, stateRootPending } from "./glla-state-root.js";
17
17
  export { normalizeProviderErrorText, providerErrorFingerprint, providerErrorPresentation, sanitizeProviderAuditReport, sanitizeProviderDisplayText } from "./quota-retry.js";
@@ -1195,7 +1195,18 @@ export interface MainModelRecovery {
1195
1195
  }
1196
1196
 
1197
1197
  /** Compact, truthful status shared by /goal status and /list show. */
1198
- export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | undefined, configuredBackups: string[] = []): string[] {
1198
+ /** Compact elapsed for recovery trajectory lines (core has no display
1199
+ * import — display's fmtElapsed stays the renderer). */
1200
+ function fmtRecoveryElapsed(ms: number): string {
1201
+ if (!Number.isFinite(ms) || ms < 0) return "?";
1202
+ const mins = Math.floor(ms / 60_000);
1203
+ if (mins < 60) return `${mins}m`;
1204
+ const hours = Math.floor(mins / 60);
1205
+ if (hours < 48) return `${hours}h${mins % 60 ? ` ${mins % 60}m` : ""}`;
1206
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
1207
+ }
1208
+
1209
+ export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | undefined, configuredBackups: string[] = [], nowMs: number = Date.now()): string[] {
1199
1210
  if (!recovery) return [];
1200
1211
  const safeBackups = configuredBackups.filter((ref) => typeof ref === "string" && ref.trim()).slice(0, MAX_MAIN_MODEL_FALLBACKS);
1201
1212
  const chain = [recovery.primary, ...safeBackups];
@@ -1205,6 +1216,20 @@ export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | unde
1205
1216
  const skipped = recovery.skipped?.length
1206
1217
  ? recovery.skipped.map((entry) => `${entry.ref} (${entry.reason})`).join(", ")
1207
1218
  : "none";
1219
+ // v0.38.93: trajectory facts — attempts + failing-since answer "is it
1220
+ // getting better or worse?", which the retry countdown alone cannot.
1221
+ // Durable counters only, never a provider-side guess.
1222
+ const attempts = typeof recovery.attempts === "number" && recovery.attempts > 0 ? recovery.attempts : 0;
1223
+ const firstMs = typeof recovery.firstFailureAt === "string" ? Date.parse(recovery.firstFailureAt) : Number.NaN;
1224
+ const trajectory = attempts > 0 || Number.isFinite(firstMs)
1225
+ ? [` Attempts: ${attempts}${Number.isFinite(firstMs) ? ` · failing ${fmtRecoveryElapsed(nowMs - firstMs)}` : ""}`]
1226
+ : [];
1227
+ // v0.38.93: name the deterministic class when classified — a held 400
1228
+ // with a countdown reads like a transient that will clear; it won't.
1229
+ // Transients stay unlabeled (cards never guess provider reasons).
1230
+ const cause = isDeterministicProviderError(recovery.providerErrorDiagnostic ?? recovery.reason)
1231
+ ? [" Cause: deterministic client error — identical retries cannot succeed"]
1232
+ : [];
1208
1233
  const lines = [
1209
1234
  `Main-model recovery: ${currentIndex >= 0 ? `${currentIndex === 0 ? "primary" : `backup ${currentIndex}/${safeBackups.length}`} selected` : "active model selected"}`,
1210
1235
  ` Order: ${chain.join(" → ")}`,
@@ -1212,6 +1237,8 @@ export function formatMainModelRecoveryStatus(recovery: MainModelRecovery | unde
1212
1237
  ...(recovery.pendingModelSwitch ? [` Pending switch: ${recovery.pendingModelSwitch}`] : []),
1213
1238
  ` Attempted: ${attempted}`,
1214
1239
  ` Skipped: ${skipped}`,
1240
+ ...trajectory,
1241
+ ...cause,
1215
1242
  ];
1216
1243
  if (recovery.retryAt) lines.push(` Retry at: ${recovery.retryAt}`);
1217
1244
  if (recovery.primaryProbeAt) lines.push(` Preferred-primary probe at: ${recovery.primaryProbeAt}`);
@@ -1448,7 +1448,10 @@ async function probeMainModelRecoveryImpl(ctx: ExtensionContext): Promise<void>
1448
1448
  }
1449
1449
  // The ordered chain has been visited for this recovery cycle. Start a
1450
1450
  // deliberate new cycle by retrying the currently selected model; the
1451
- // next failure can then walk primary → backup 1 → … again.
1451
+ // next failure can then walk primary → backup 1 → … again. (2026-09-23:
1452
+ // a single-model chain still needs this probe — it IS the retry. An
1453
+ // attempted "quiet wait" here wedged recovery into a park loop that
1454
+ // never retried; blocked-pause-autoclear pins the probe contract.)
1452
1455
  // Audit 2026-09-07 (HIGH, scoped): each new cycle consumes backoff
1453
1456
  // budget (attempts+1, so the next failure's envelope delay grows) and
1454
1457
  // honors the 24h horizon (past the wall the reset holds for manual
@@ -173,6 +173,7 @@ import {
173
173
  setContinuationRearmStreak,
174
174
  setContinuationRearmSince,
175
175
  resetContinuationDispatchState,
176
+ resetContinuationInitialSend,
176
177
  noteUserMessageForDispatch,
177
178
  sendTerminalCompletionNotice,
178
179
  type ContinuationFlags,
@@ -493,6 +494,11 @@ function clearInBandProviderFailure(): void {
493
494
  // session_start, and manual parks — never on starved stops (the wedge
494
495
  // persists) so a hot context cannot lap the budget forever.
495
496
  let lengthExhaustionEpisodes = 0;
497
+ // Field 2026-09-22: every send re-projects the same bloated history, which
498
+ // logged 719 identical payload_guard_eviction entries in a day. Dedupe by
499
+ // fingerprint — first occurrence lands, identical repeats are silent, and
500
+ // any value change (or generation bump) re-arms.
501
+ let lastPayloadEvictionFingerprint = "";
496
502
  /** A manual resume starts a fresh relentless cycle: a user pause between
497
503
  an episode-1 wedge and the next one must not make that wedge park one
498
504
  cycle early. */
@@ -1426,6 +1432,7 @@ export function registerGoalRuntime(pi: ExtensionAPI): void {
1426
1432
  clearToolActivityState();
1427
1433
  bindSubagentRpcHost(pi.events, sessionGeneration);
1428
1434
  clearDraftingState();
1435
+ resetContinuationInitialSend(); // v0.38.94: fresh host context never saw the brief
1429
1436
  // An auditor belonging to the disposed generation cannot block the fresh
1430
1437
  // session's recovery gate; its finally block is generation-guarded too.
1431
1438
  completionAuditInFlight = false;
@@ -3108,12 +3115,17 @@ async function handleHotLengthExhaustion(
3108
3115
  });
3109
3116
  }
3110
3117
  if (projection.evicted.length > 0) {
3111
- appendLedger(ctx.cwd, "payload_guard_eviction", {
3112
- evicted: projection.evicted.length,
3113
- bytesFreed: projection.totalImageBytes - projection.remainingImageBytes,
3114
- remainingImageBytes: projection.remainingImageBytes,
3115
- generation: sessionGeneration,
3116
- });
3118
+ const evictionFingerprint = `${sessionGeneration}:${projection.evicted.length}:${projection.totalImageBytes - projection.remainingImageBytes}:${projection.remainingImageBytes}:${projection.remainingImageCount}`;
3119
+ if (evictionFingerprint !== lastPayloadEvictionFingerprint) {
3120
+ lastPayloadEvictionFingerprint = evictionFingerprint;
3121
+ appendLedger(ctx.cwd, "payload_guard_eviction", {
3122
+ evicted: projection.evicted.length,
3123
+ bytesFreed: projection.totalImageBytes - projection.remainingImageBytes,
3124
+ remainingImageBytes: projection.remainingImageBytes,
3125
+ remainingImageCount: projection.remainingImageCount,
3126
+ generation: sessionGeneration,
3127
+ });
3128
+ }
3117
3129
  }
3118
3130
  if (checkpointProjection.removedPayloads > 0) {
3119
3131
  appendLedger(ctx.cwd, "context_checkpoint_projection", {
@@ -323,20 +323,9 @@ export function isQuotaHorizonExempt(raw: string | undefined): boolean {
323
323
  return signal === "rate-limit" || signal === "plan-quota";
324
324
  }
325
325
 
326
- // v0.38.92 (field 2026-09-21: 12 images vs a 4-image model cap retried 7.5h):
327
- // a deterministic HTTP 400 client error can never succeed on an identical
328
- // retry — only a changed request (fewer images, different model, fixed
329
- // params) can. Retrying it on the ladder burns the horizon (or loops
330
- // forever under aggressiveMode) while telling the user "retrying
331
- // automatically". Match the error-type markers, never a bare "400" (token
332
- // counts and limits quote 400s constantly). Callers hold for manual resume
333
- // with the fix directions instead of scheduling.
334
- const DETERMINISTIC_CLIENT_ERROR = /badrequesterror|invalid_request_error|"code"\s*:\s*"400"/i;
335
-
336
- export function isDeterministicProviderError(raw: string | undefined): boolean {
337
- if (typeof raw !== "string" || !raw.trim()) return false;
338
- return DETERMINISTIC_CLIENT_ERROR.test(raw);
339
- }
326
+ // v0.38.92 classifier lives in quota-retry.ts (shared with the display
327
+ // layer); re-exported here so existing importers keep working.
328
+ export { isDeterministicProviderError } from "./quota-retry.js";
340
329
 
341
330
  /** v0.38.69 (Antigravity port): sleep-until-reset for quota-class failures
342
331
  * with an explicit upstream reset hint. Returns undefined (keep the blind
@@ -31,6 +31,11 @@ export const DEFAULT_IMAGE_BUDGET_BYTES = 16 * 1024 * 1024;
31
31
  * current turn almost always needs the newest visual state. */
32
32
  export const DEFAULT_KEEP_RECENT_IMAGES = 2;
33
33
 
34
+ /** Max inline image blocks in one outgoing request. Field 2026-09-21: a
35
+ * provider 400'd `Image count 12 exceeds limit 4 per request` on small
36
+ * screenshots that fit the byte budget — count is a separate wall. */
37
+ export const DEFAULT_MAX_IMAGES_PER_REQUEST = 4;
38
+
34
39
  export interface ImageBlockLocation {
35
40
  messageIndex: number;
36
41
  blockIndex: number;
@@ -52,11 +57,16 @@ export interface EvictionResult {
52
57
  totalImageBytes: number;
53
58
  /** Cumulative image bytes AFTER eviction. */
54
59
  remainingImageBytes: number;
60
+ /** Image blocks BEFORE eviction. */
61
+ totalImageCount: number;
62
+ /** Image blocks AFTER eviction. */
63
+ remainingImageCount: number;
55
64
  }
56
65
 
57
66
  export interface PayloadGuardOptions {
58
67
  imageBudgetBytes?: number;
59
68
  keepRecentImages?: number;
69
+ maxImagesPerRequest?: number;
60
70
  }
61
71
 
62
72
  interface ImageBlockLike {
@@ -101,10 +111,12 @@ function evictionPlaceholder(bytes: number): { type: string; text: string } {
101
111
 
102
112
  /**
103
113
  * Project the outgoing message list with cumulative inline-image bytes
104
- * bounded to `imageBudgetBytes`. Oldest images are evicted first; the most
105
- * recent `keepRecentImages` images are never evicted (best effort when the
106
- * budget cannot be met even without them). Returns the input array identity
107
- * when nothing needs eviction — callers can cheaply detect the no-op.
114
+ * bounded to `imageBudgetBytes` AND image blocks bounded to
115
+ * `maxImagesPerRequest`. Oldest images are evicted first; the most recent
116
+ * `keepRecentImages` images are never evicted (best effort when neither
117
+ * bound can be met without them — the floor always wins). Returns the input
118
+ * array identity when nothing needs eviction — callers can cheaply detect
119
+ * the no-op.
108
120
  */
109
121
  export function evictStaleImages(
110
122
  messages: readonly unknown[],
@@ -116,27 +128,31 @@ export function evictStaleImages(
116
128
  const keepRecent = typeof opts.keepRecentImages === "number" && opts.keepRecentImages >= 0
117
129
  ? opts.keepRecentImages
118
130
  : DEFAULT_KEEP_RECENT_IMAGES;
131
+ const maxImages = typeof opts.maxImagesPerRequest === "number" && opts.maxImagesPerRequest > 0
132
+ ? Math.floor(opts.maxImagesPerRequest)
133
+ : DEFAULT_MAX_IMAGES_PER_REQUEST;
119
134
 
120
135
  const blocks = collectImageBlocks(messages);
121
136
  const totalImageBytes = blocks.reduce((sum, b) => sum + b.bytes, 0);
122
- if (totalImageBytes <= budget) {
123
- return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes };
137
+ if (totalImageBytes <= budget && blocks.length <= maxImages) {
138
+ return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes, totalImageCount: blocks.length, remainingImageCount: blocks.length };
124
139
  }
125
140
 
126
- // Evict oldest-first; the newest `keepRecent` are exempt. When even the
127
- // exempt set exceeds the budget the eviction stops there — best effort,
128
- // never a destructive sweep of the current visual state.
141
+ // Evict oldest-first until BOTH bounds hold; the newest `keepRecent` are
142
+ // exempt. When even the exempt set breaks a bound the eviction stops
143
+ // there — best effort, never a destructive sweep of the current visual
144
+ // state.
129
145
  const evict = new Set<ImageBlockLocation>();
130
146
  let remaining = totalImageBytes;
131
147
  const exempt = blocks.slice(Math.max(0, blocks.length - keepRecent));
132
148
  for (const block of blocks) {
133
- if (remaining <= budget) break;
149
+ if (remaining <= budget && blocks.length - evict.size <= maxImages) break;
134
150
  if (exempt.includes(block)) continue;
135
151
  evict.add(block);
136
152
  remaining -= block.bytes;
137
153
  }
138
154
  if (evict.size === 0) {
139
- return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes };
155
+ return { messages, evicted: [], totalImageBytes, remainingImageBytes: totalImageBytes, totalImageCount: blocks.length, remainingImageCount: blocks.length };
140
156
  }
141
157
 
142
158
  // Group evictions per message so each affected message is copied once.
@@ -160,7 +176,7 @@ export function evictStaleImages(
160
176
  return { ...(message as object), content: newContent };
161
177
  });
162
178
 
163
- return { messages: projected, evicted: [...byMessage.values()].flat(), totalImageBytes, remainingImageBytes: remaining };
179
+ return { messages: projected, evicted: [...byMessage.values()].flat(), totalImageBytes, remainingImageBytes: remaining, totalImageCount: blocks.length, remainingImageCount: blocks.length - evict.size };
164
180
  }
165
181
 
166
182
  /**
@@ -317,6 +317,20 @@ export function isBillingError(error: string | undefined): boolean {
317
317
  return quotaSignal(error) === "billing";
318
318
  }
319
319
 
320
+ // v0.38.92 (field 2026-09-21: 12 images vs a 4-image model cap retried
321
+ // 7.5h): a deterministic HTTP 400 client error can never succeed on an
322
+ // identical retry — only a changed request (fewer images, different
323
+ // model, fixed params) can. Match the error-type markers, never a bare
324
+ // "400" (token counts and limits quote 400s constantly). Moved here from
325
+ // main-model-recovery.ts in v0.38.93 so the display layer can share the
326
+ // classifier without an import cycle (it is re-exported there).
327
+ const DETERMINISTIC_CLIENT_ERROR = /badrequesterror|invalid_request_error|"code"\s*:\s*"400"/i;
328
+
329
+ export function isDeterministicProviderError(raw: string | undefined): boolean {
330
+ if (typeof raw !== "string" || !raw.trim()) return false;
331
+ return DETERMINISTIC_CLIENT_ERROR.test(raw);
332
+ }
333
+
320
334
  /** Match recoverable provider rate/plan walls and explicit billing walls.
321
335
  * Ambiguous `temporarily unavailable`, ordinary 403s, and generic network
322
336
  * failures intentionally return false. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-list-loop-audit",
3
- "version": "0.38.92",
3
+ "version": "0.38.95",
4
4
  "description": "Mission control for long running pi work: interview drafted goals, an audited task queue, and metric or audit loops that run for hours. Every completion is rechecked by a detached auditor with raw evidence, while Confirm drafts, decision pauses, and consent gates keep you in charge.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "dracon",