pi-goal-list-loop-audit 0.38.91 → 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,24 @@
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
+
17
+ ## 0.38.92 — Deterministic 400s hold; dethroned sessions named (2026-09-22)
18
+
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).
20
+ - A session that loses the state-root race is now told exactly that ("owned by another session … not a subagent refusal") instead of being misdiagnosed as a subagent session.
21
+
3
22
  ## 0.38.91 — Tool descriptions enumerate plan/audit/verify/add (2026-09-21)
4
23
 
5
24
  - `/goal plan`, `/goal audit`, `/goal verify`, `/list plan`, and `/list add` existed but were undiscoverable from the tool descriptions. Both descriptions now enumerate them with one-line roles; a pin keeps the enumerations (audit/POST-090-IMPROVEMENT-SWEEP-2026-09-21.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.91 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}`);
@@ -31,6 +31,7 @@ import {
31
31
  mainModelFailureDelayMs,
32
32
  mainModelPrimaryProbeDelayMs,
33
33
  mainModelRetryDelayMs,
34
+ isDeterministicProviderError,
34
35
  isQuotaHorizonExempt,
35
36
  quotaResetSleepMs,
36
37
  isCompactionInFlightSince,
@@ -800,6 +801,15 @@ export function setMainModelRecoveryPause(ctx: ExtensionContext, recovery: MainM
800
801
  };
801
802
  const now = Date.now();
802
803
  const deadlineMs = normalized.autoRetryUntil ? Date.parse(normalized.autoRetryUntil) : Number.NaN;
804
+ // v0.38.92: deterministic 400s never schedule — an identical retry cannot
805
+ // succeed, so the ladder would burn (forever under aggressiveMode) while
806
+ // promising "retrying automatically". Hold for manual resume with the fix
807
+ // directions; the user's resume re-probes a CHANGED request. Checked
808
+ // before the horizon/quota logic so it wins in every mode.
809
+ if (isDeterministicProviderError(normalized.providerErrorDiagnostic ?? normalized.reason)) {
810
+ holdMainModelRecovery(ctx, normalized, "the provider refused the request itself (deterministic client error — e.g. too many images for this model). Switch model or trim the request, then resume");
811
+ return false;
812
+ }
803
813
  const requestedDelayMs = Math.max(1_000, delayMs);
804
814
  // v0.38.69 (Antigravity port): quota waits never park at the horizon —
805
815
  // a rate-limit/plan-quota wall is transient, so the hold below is
@@ -1438,7 +1448,10 @@ async function probeMainModelRecoveryImpl(ctx: ExtensionContext): Promise<void>
1438
1448
  }
1439
1449
  // The ordered chain has been visited for this recovery cycle. Start a
1440
1450
  // deliberate new cycle by retrying the currently selected model; the
1441
- // 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.)
1442
1455
  // Audit 2026-09-07 (HIGH, scoped): each new cycle consumes backoff
1443
1456
  // budget (attempts+1, so the next failure's envelope delay grows) and
1444
1457
  // honors the 24h horizon (past the wall the reset holds for manual
@@ -1627,7 +1640,11 @@ export async function recoverMainModelFromSendStorm(ctx: ExtensionContext, kind:
1627
1640
  if (!current) return;
1628
1641
  const recovery = state.mainModelRecovery;
1629
1642
  if (!recovery) return;
1630
- if (setMainModelRecoveryPause(ctx, { ...recovery, kind: kind === "loop" ? "loop" : "goal", active: current, resumeCurrent: true }, 1_000)) {
1643
+ // v0.38.92: the switch retires the old episode's diagnostic — it
1644
+ // describes the previous model's failure, and a stale deterministic
1645
+ // marker must not hold the fresh model's probe. Reason restarts from
1646
+ // the storm failure that triggered the switch.
1647
+ if (setMainModelRecoveryPause(ctx, { ...recovery, kind: kind === "loop" ? "loop" : "goal", active: current, resumeCurrent: true, reason: mainModelRecoveryReason(failure), providerErrorDiagnostic: undefined }, 1_000)) {
1631
1648
  flags.mainModelAbortForRecovery = true;
1632
1649
  try { ctx.abort(); } catch { /* best effort; recovery guard prevents re-send storms */ }
1633
1650
  scheduleMainModelRecoveryTimer(ctx, 1_000);
@@ -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", {
@@ -1884,8 +1884,15 @@ function isHostLifecycleSessionStart(event: unknown): boolean {
1884
1884
 
1885
1885
  const FOREIGN_SESSION_TOOL_MESSAGE =
1886
1886
  "This tool changes goal/loop/list state, which only the MAIN session owns — you are running in a subagent session. Report back to the main agent; it owns the goal and can call this tool.";
1887
- const FOREIGN_SESSION_TOOL_MESSAGE_WITH_STATE_ROOT_HINT =
1888
- `${FOREIGN_SESSION_TOOL_MESSAGE} If you also see the state-root read-only warning, close the other host or switch to sessionDir via /glla settings → State root, then start a fresh session.`;
1887
+ /** Refusal when the caller lost the workingDir state-root race (field
1888
+ * 2026-09-21: a dethroned MAIN session got the bare subagent message,
1889
+ * which misdiagnoses — it is not a worker, it lost the root to another
1890
+ * live session). Keeps the v0.35.72 pinned contract (MAIN-owner clause,
1891
+ * "state-root read-only", sessionDir cure) but replaces the false
1892
+ * "subagent session" diagnosis with the dethroned one. Replaces
1893
+ * FOREIGN_SESSION_TOOL_MESSAGE_WITH_STATE_ROOT_HINT. */
1894
+ const DETHRONED_SESSION_TOOL_MESSAGE =
1895
+ "This tool changes goal/loop/list state, which only the MAIN session owns — but this is not a subagent refusal: another live session holds this folder's state root, so this session is state-root read-only. Close the other session, start a fresh session here to take the root back (/glla owner inspects the holder), or switch to sessionDir via /glla settings → State root.";
1889
1896
 
1890
1897
  /** Refusal message when a state-mutating tool is called from a subagent session, else null. */
1891
1898
  function foreignToolGuard(execCtx: unknown): string | null {
@@ -1901,9 +1908,10 @@ function foreignToolGuard(execCtx: unknown): string | null {
1901
1908
  if (tryAbsorbHostSuccessor(c, "tool-call")) return null;
1902
1909
  if (isForeignCtx(c)) {
1903
1910
  // v0.35.72: when the cwd is also denied by the workingDir state-root
1904
- // owner, the two warnings co-occur. Correlate them so "MAIN became
1905
- // subagent and cannot be cured" has an actionable cure.
1906
- if (c && processOwnerDeniedCwd === c.cwd) return FOREIGN_SESSION_TOOL_MESSAGE_WITH_STATE_ROOT_HINT;
1911
+ // owner, the two warnings co-occur. v0.38.92: name the dethroned
1912
+ // diagnosis, not the subagent one — the caller is usually a former
1913
+ // main session that lost the root race (field 2026-09-21).
1914
+ if (c && processOwnerDeniedCwd === c.cwd) return DETHRONED_SESSION_TOOL_MESSAGE;
1907
1915
  return FOREIGN_SESSION_TOOL_MESSAGE;
1908
1916
  }
1909
1917
  // Post-park the owner is nulled; the dead-owner record means only the
@@ -323,6 +323,10 @@ export function isQuotaHorizonExempt(raw: string | undefined): boolean {
323
323
  return signal === "rate-limit" || signal === "plan-quota";
324
324
  }
325
325
 
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";
329
+
326
330
  /** v0.38.69 (Antigravity port): sleep-until-reset for quota-class failures
327
331
  * with an explicit upstream reset hint. Returns undefined (keep the blind
328
332
  * ladder) unless ALL hold: a rate-limit/plan-quota signal, an upstream
@@ -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.91",
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",