pi-mega-compact 0.8.14 → 0.8.16

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.
Files changed (89) hide show
  1. package/README.md +51 -463
  2. package/dist/extensions/dashboard-client/src/hooks/useApi.js +51 -0
  3. package/dist/extensions/dashboard-client/src/hooks/useSSE.js +63 -0
  4. package/dist/extensions/dashboard-server/html.js +15 -2
  5. package/dist/extensions/mega-cache-replay.test.js +4 -2
  6. package/dist/extensions/mega-compact-s38.test.js +317 -0
  7. package/dist/extensions/mega-compact.js +14 -0
  8. package/dist/extensions/mega-compact.test.js +37 -8
  9. package/dist/extensions/mega-config.js +5 -0
  10. package/dist/extensions/mega-events/agent-handlers.js +184 -4
  11. package/dist/extensions/mega-events/context-handler.js +35 -2
  12. package/dist/extensions/mega-events/error-classifier.js +118 -0
  13. package/dist/extensions/mega-events.js +1 -0
  14. package/dist/extensions/mega-runtime/state.js +82 -0
  15. package/dist/extensions/mega-runtime/state.test.js +37 -0
  16. package/dist/extensions/mega-runtime/widget.js +6 -2
  17. package/dist/extensions/mega-runtime/widget.test.js +47 -0
  18. package/dist/extensions/mega-teamrun.test.js +14 -1
  19. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js → AchievementTiles-BozP_h3c.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{AchievementTiles-BH26e95w.js.map → AchievementTiles-BozP_h3c.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js → AchievementsTab-DKgqx1RQ.js} +2 -2
  22. package/extensions/dashboard-client/dist/assets/{AchievementsTab-B94wvRV_.js.map → AchievementsTab-DKgqx1RQ.js.map} +1 -1
  23. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js → CacheTab-CPvso4-0.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{CacheTab-DPmw3NHR.js.map → CacheTab-CPvso4-0.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js +2 -0
  26. package/extensions/dashboard-client/dist/assets/ConfigTab-DXD7zbqy.js.map +1 -0
  27. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js → EventsTab-BGeuYvM3.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{EventsTab-B5PicfFw.js.map → EventsTab-BGeuYvM3.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js → GameTab-Qj19W1Y-.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{GameTab-CsVwp78a.js.map → GameTab-Qj19W1Y-.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js → MetricsTab-DA27vA95.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{MetricsTab-Cf72UewX.js.map → MetricsTab-DA27vA95.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js → OverviewTab-lVGR1Jl_.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{OverviewTab-Dh6aG3gy.js.map → OverviewTab-lVGR1Jl_.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js → ReposTab-_HNR3qYj.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{ReposTab-HXf2dorl.js.map → ReposTab-_HNR3qYj.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js → TimeSavedCard-B7aPcBMs.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-kUnoospE.js.map → TimeSavedCard-B7aPcBMs.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js → index-B2JJExE9.js} +3 -3
  40. package/extensions/dashboard-client/dist/assets/{index-DMntLu-8.js.map → index-B2JJExE9.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/index.html +1 -1
  42. package/extensions/dashboard-client/src/styles/base.css +2 -1
  43. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +19 -2
  44. package/extensions/dashboard-server/html.ts +15 -2
  45. package/extensions/mega-cache-replay.test.ts +3 -3
  46. package/extensions/mega-compact-s38.test.ts +330 -0
  47. package/extensions/mega-compact.test.ts +41 -13
  48. package/extensions/mega-compact.ts +14 -0
  49. package/extensions/mega-config.ts +24 -0
  50. package/extensions/mega-dashboard.ts +7 -0
  51. package/extensions/mega-events/agent-handlers.ts +179 -5
  52. package/extensions/mega-events/context-handler.ts +31 -2
  53. package/extensions/mega-events/error-classifier.ts +109 -0
  54. package/extensions/mega-events.ts +1 -0
  55. package/extensions/mega-runtime/helpers.ts +5 -0
  56. package/extensions/mega-runtime/state.test.ts +33 -0
  57. package/extensions/mega-runtime/state.ts +81 -1
  58. package/extensions/mega-runtime/widget.test.ts +48 -0
  59. package/extensions/mega-runtime/widget.ts +6 -2
  60. package/extensions/mega-teamrun.test.ts +13 -1
  61. package/package.json +4 -1
  62. package/dist/extensions/dashboard-server/helpers.js +0 -37
  63. package/dist/extensions/dashboard-server/html/all-repos-tab.js +0 -26
  64. package/dist/extensions/dashboard-server/html/body-open.js +0 -23
  65. package/dist/extensions/dashboard-server/html/current-repo-tab.js +0 -130
  66. package/dist/extensions/dashboard-server/html/head-open.js +0 -16
  67. package/dist/extensions/dashboard-server/html/high-score-tab.js +0 -25
  68. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +0 -26
  69. package/dist/extensions/dashboard-server/html/script.js +0 -259
  70. package/dist/extensions/dashboard-server/html/styles.js +0 -103
  71. package/dist/extensions/dashboard-server/html/summary-tab.js +0 -19
  72. package/dist/extensions/dashboard-server/html-template.js +0 -41
  73. package/dist/src/store/sqlite/connection.js +0 -35
  74. package/dist/src/store/sqlite/index-store.js +0 -167
  75. package/dist/src/store/sqlite/memory.js +0 -54
  76. package/dist/src/store/sqlite/minhash-lsh.js +0 -47
  77. package/dist/src/store/sqlite/sessions.js +0 -39
  78. package/dist/src/store/sqlite/transaction.js +0 -19
  79. package/dist/src/vectorStore/add.js +0 -260
  80. package/dist/src/vectorStore/dedup.js +0 -52
  81. package/dist/src/vectorStore/index.js +0 -10
  82. package/dist/src/vectorStore/queries.js +0 -83
  83. package/dist/src/vectorStore/search.js +0 -95
  84. package/dist/src/vectorStore/session.js +0 -19
  85. package/dist/src/vectorStore/store.js +0 -105
  86. package/dist/src/vectorStore/types.js +0 -6
  87. package/dist/src/vectorStore/utils.js +0 -23
  88. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js +0 -2
  89. package/extensions/dashboard-client/dist/assets/ConfigTab-C3g3smRL.js.map +0 -1
@@ -4,6 +4,7 @@ import { recordScore } from "../../src/store/sqlite.js";
4
4
  import { evaluateAndUnlockAchievements } from "../../src/store/sqlite/game-achievements.js";
5
5
  import { isMegaCache } from "../../src/game/scoring.js";
6
6
  import { resolveRepoRoot } from "../mega-config.js";
7
+ import { classifyError } from "./error-classifier.js";
7
8
  /** Register agent/turn tracking event handlers. */
8
9
  export function registerAgentHandlers(pi, runtime, config) {
9
10
  // ---- Agent tracking for real-time widget + status-line updates ---------
@@ -37,10 +38,31 @@ export function registerAgentHandlers(pi, runtime, config) {
37
38
  const idle = ctx.isIdle?.() ?? true;
38
39
  const queued = ctx.hasPendingMessages?.() ?? false;
39
40
  const now = Date.now();
41
+ // S38.5: read LIVE pressure from ctx.getContextUsage() instead of the
42
+ // stale runtime.lastCtxTokens (only updated by the `context` event).
43
+ // agent_end may fire without a preceding context event this turn (e.g.
44
+ // a sub-agent settling), so the cached value can be null/stale and the
45
+ // durable-trim branch would be unreachable. ctx.getContextUsage() is the
46
+ // authoritative live reading (mirrors context-handler.ts:86). Fall back
47
+ // to the cached value only if the ctx omits it.
48
+ const liveUsage = ctx.getContextUsage?.();
49
+ const liveTokens = typeof liveUsage?.tokens === "number"
50
+ ? liveUsage.tokens
51
+ : runtime.lastCtxTokens ?? 0;
52
+ // Keep the cache fresh for snapshot()/diag regardless of which source we use.
53
+ if (typeof liveUsage?.tokens === "number") {
54
+ runtime.lastCtxTokens = liveUsage.tokens;
55
+ }
56
+ if (typeof liveUsage?.percent === "number") {
57
+ runtime.lastCtxPercent = liveUsage.percent;
58
+ }
59
+ if (typeof liveUsage?.contextWindow === "number") {
60
+ runtime.lastCtxWindow = liveUsage.contextWindow;
61
+ }
40
62
  // DIAG (team-run relief): surface whether the agent is idle + over
41
63
  // threshold at agent_end so we can see if a mid-run durable-trim trigger
42
64
  // *should* have fired but didn't.
43
- const overThreshold = (runtime.lastCtxTokens ?? 0) >= runtime.effectiveThreshold;
65
+ const overThreshold = liveTokens >= runtime.effectiveThreshold;
44
66
  runtime.diagAgentEndIdle++;
45
67
  runtime.logger.info("agent-end-idle", {
46
68
  sessionId: runtime.rt.sessionId,
@@ -87,11 +109,20 @@ export function registerAgentHandlers(pi, runtime, config) {
87
109
  // synchronous `piCompactWouldNoop` branch check misses a native
88
110
  // compaction that hasn't appended its entry yet — calling
89
111
  // ctx.compact() then races with pi and throws "Already compacted"
90
- // to the user. The `lastCompactAt` cooldown (updated by the
112
+ // to the user. The `lastNativeCompactAt` cooldown (updated by the
91
113
  // session_compact listener for EVERY compaction, native or
92
114
  // extension-supplied) closes that race window.
115
+ //
116
+ // S38.5: strict race guard widens the cooldown 10s -> 30s AND defers
117
+ // ctx.compact() via setTimeout(500) with a re-check, so pi's
118
+ // about-to-run native _checkCompaction can append its `compaction`
119
+ // branch entry first (closes the first-race-in-burst window). Gated
120
+ // by MEGACOMPACT_RACE_GUARD_STRICT (default true); false reverts to
121
+ // the v0.7.4 synchronous 10s guard. Mirrors the legacy path in
122
+ // context-handler.ts:258-287 so both call sites stay in sync.
123
+ const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
93
124
  const sinceCompact = now - (runtime.rt.lastNativeCompactAt ?? 0);
94
- if (sinceCompact < 10_000) {
125
+ if (sinceCompact < cooldownMs) {
95
126
  runtime.diagAgentEndDurableSkipRecent++;
96
127
  }
97
128
  else if (!piCompactWouldNoop(ctx)) {
@@ -103,7 +134,35 @@ export function registerAgentHandlers(pi, runtime, config) {
103
134
  thresholdTokens: config.thresholdTokens,
104
135
  queued,
105
136
  });
106
- ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort. Race-guarded by lastCompactAt cooldown above (ctx.compact returns void → throw is surfaced by pi as compaction_end; the cooldown prevents the call entirely).
137
+ if (config.raceGuardStrict) {
138
+ // Strict: defer ctx.compact() with a re-check so pi's
139
+ // about-to-run native _checkCompaction can append its
140
+ // `compaction` branch entry first. setTimeout(500) — pi's
141
+ // compaction-summary append is async I/O, so queueMicrotask
142
+ // would re-check before it lands.
143
+ const stamp = runtime.rt.lastNativeCompactAt;
144
+ const liveSid = runtime.rt.sessionId;
145
+ setTimeout(() => {
146
+ try {
147
+ if (runtime.rt.sessionId !== liveSid)
148
+ return; // session reset
149
+ const since2 = now - (runtime.rt.lastNativeCompactAt ?? 0);
150
+ if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs)
151
+ return;
152
+ if (piCompactWouldNoop(ctx))
153
+ return;
154
+ ctx.compact({
155
+ customInstructions: undefined,
156
+ }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated.
157
+ }
158
+ catch {
159
+ /* non-fatal */
160
+ }
161
+ }, 500);
162
+ }
163
+ else {
164
+ ctx.compact({ customInstructions: undefined }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; agent settled so no in-flight abort. Race-guarded by lastNativeCompactAt cooldown above (ctx.compact returns void → throw is surfaced by pi as compaction_end; the cooldown prevents the call entirely).
165
+ }
107
166
  didDurableTrim = true;
108
167
  }
109
168
  }
@@ -141,6 +200,7 @@ export function registerAgentHandlers(pi, runtime, config) {
141
200
  pi.on("turn_start", async (event, ctx) => {
142
201
  runtime.currentTurn = event.turnIndex;
143
202
  runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
203
+ runtime.rt.errorRetryCount = 0; // S38: reset error-retry counter each user turn
144
204
  runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
145
205
  runtime.snapshot(ctx);
146
206
  });
@@ -218,5 +278,125 @@ export function registerAgentHandlers(pi, runtime, config) {
218
278
  runtime.rt.lengthStopPending = true;
219
279
  runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
220
280
  }
281
+ // S38: broader error-retry safety net. S28 only catches stopReason==='length';
282
+ // this catches ALL other error types (provider failure, network timeout, 5xx,
283
+ // 429, auth, compaction-noop) that surface at turn_end. Non-fatal: wrapped in
284
+ // try/catch so a classifier/retry failure never breaks the agent loop.
285
+ // PREVENT-PI-003: retry nudge fires via pi.sendUserMessage (user-role).
286
+ try {
287
+ // (1) S28 owns length — skip the classifier entirely for it.
288
+ const sr = event.message?.stopReason;
289
+ if (sr === 'length') {
290
+ // S28 handles; nothing for S38 to do here.
291
+ }
292
+ else {
293
+ const category = classifyError(event.message);
294
+ if (category === null) {
295
+ // (3) success / normal flow / unknown-but-non-retryable — reset.
296
+ runtime.rt.errorRetryCount = 0;
297
+ runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset on success
298
+ }
299
+ else if (category === 'compaction-noop') {
300
+ // (4) pi race / manual compact catch — NOT retryable. The compaction
301
+ // already succeeded via pi's native path; retrying would race again
302
+ // (FAIL-2026071701). Log a diagnostic, reset the counter, and surface
303
+ // the original error WITHOUT firing a retry nudge.
304
+ runtime.rt.errorRetryCount = 0;
305
+ runtime.rt.consecutiveErrors = 0; // S38.6: circuit-breaker reset
306
+ runtime.dashboard.event('compaction_noop_diagnostic', {
307
+ turnIndex: event.turnIndex,
308
+ sessionId: runtime.rt.sessionId,
309
+ });
310
+ runtime.logger.info('compaction-noop-diagnostic', {
311
+ sessionId: runtime.rt.sessionId,
312
+ turnIndex: event.turnIndex,
313
+ });
314
+ }
315
+ else {
316
+ // (5) transient or permanent — retry with exponential backoff.
317
+ // S38.7: hard-stop switch — bypass ALL retry logic when set.
318
+ if (config.errorRetryHardStop) {
319
+ runtime.rt.errorRetryCount = 0;
320
+ runtime.dashboard.event('error_retry_disabled', {
321
+ category,
322
+ turnIndex: event.turnIndex,
323
+ reason: 'hard-stop',
324
+ });
325
+ return; // early exit — no retry
326
+ }
327
+ // S38.6: circuit-breaker — stop retrying after too many consecutive errors.
328
+ runtime.rt.consecutiveErrors++;
329
+ if (runtime.rt.consecutiveErrors > config.maxConsecutiveErrors) {
330
+ runtime.dashboard.event('error_retry_circuit_open', {
331
+ consecutive: runtime.rt.consecutiveErrors,
332
+ max: config.maxConsecutiveErrors,
333
+ turnIndex: event.turnIndex,
334
+ });
335
+ runtime.logger.warn('error-retry-circuit-open', {
336
+ sessionId: runtime.rt.sessionId,
337
+ consecutive: runtime.rt.consecutiveErrors,
338
+ max: config.maxConsecutiveErrors,
339
+ });
340
+ return; // early exit — circuit breaker tripped
341
+ }
342
+ const max = category === 'transient'
343
+ ? config.autoRetryTransientMax
344
+ : config.autoRetryPermanentMax;
345
+ // max === 0 disables the category entirely (revert to S28-only).
346
+ if (max <= 0) {
347
+ runtime.rt.errorRetryCount = 0;
348
+ }
349
+ else {
350
+ runtime.rt.errorRetryCount++;
351
+ if (runtime.rt.errorRetryCount > max) {
352
+ // Exhausted — surface the error, reset for the next burst.
353
+ runtime.dashboard.event('error_retry_exhausted', {
354
+ category,
355
+ count: runtime.rt.errorRetryCount,
356
+ max,
357
+ turnIndex: event.turnIndex,
358
+ });
359
+ runtime.logger.info('error-retry-exhausted', {
360
+ sessionId: runtime.rt.sessionId,
361
+ category,
362
+ count: runtime.rt.errorRetryCount,
363
+ max,
364
+ });
365
+ runtime.rt.errorRetryCount = 0;
366
+ }
367
+ else {
368
+ // S38: fire the retry nudge. Each error turn fires its own
369
+ // nudge up to `max` — we intentionally do NOT debounce on the
370
+ // backoff window (errorRetryUntil) here, because that would
371
+ // suppress turns 2..max on a fast-erroring provider (the exact
372
+ // scenario this feature targets) and the user could see zero
373
+ // retries followed by error_retry_exhausted. The per-turn cap
374
+ // (errorRetryCount <= max) plus the session circuit breaker
375
+ // (consecutiveErrors > maxConsecutiveErrors) already bound the
376
+ // loop, so a tight turn_end storm cannot busy-loop unbounded.
377
+ // errorRetryUntil + errorRetryBackoffMs() are retained on the
378
+ // runtime for future/optional pacing but are not gating.
379
+ runtime.dashboard.event('error_retry', {
380
+ category,
381
+ count: runtime.rt.errorRetryCount,
382
+ max,
383
+ turnIndex: event.turnIndex,
384
+ });
385
+ runtime.logger.info('error-retry', {
386
+ sessionId: runtime.rt.sessionId,
387
+ category,
388
+ count: runtime.rt.errorRetryCount,
389
+ max,
390
+ });
391
+ // PREVENT-PI-003: user-role sendUserMessage only.
392
+ pi.sendUserMessage('[mega-compact] the last turn ended with an error; please retry.');
393
+ }
394
+ }
395
+ }
396
+ }
397
+ }
398
+ catch {
399
+ /* non-fatal: a classifier/retry failure never breaks the agent loop */
400
+ }
221
401
  });
222
402
  }
@@ -208,10 +208,43 @@ export function registerContextHandler(pi, runtime, config) {
208
208
  // NATIVE compaction just fired (avoids racing pi and surfacing a spurious
209
209
  // "Already compacted" / "Nothing to compact" toast). Uses lastNativeCompactAt
210
210
  // (NOT lastCompactAt, which runCompact also stamps for our own checkpoint).
211
+ // S38.5: strict race guard widens the cooldown 10s -> 30s (gated by
212
+ // MEGACOMPACT_RACE_GUARD_STRICT; false reverts to v0.7.4 10s).
213
+ const cooldownMs = config.raceGuardStrict ? 30_000 : 10_000;
211
214
  const sinceCompact = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
212
- if (sinceCompact < 10_000 || piCompactWouldNoop(ctx))
215
+ if (sinceCompact < cooldownMs || piCompactWouldNoop(ctx))
213
216
  return;
214
- ctx.compact({ customInstructions: undefined }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void not catchable; the cooldown prevents the call)
217
+ // S38.5: defer ctx.compact() with a re-check so pi's about-to-run native
218
+ // _checkCompaction can append its `compaction` branch entry first (closes
219
+ // the first-race-in-burst window). setTimeout(500) — pi's compaction-summary
220
+ // append is async I/O, so queueMicrotask would re-check before it lands.
221
+ // Non-strict (v0.7.4) keeps the synchronous call.
222
+ if (config.raceGuardStrict) {
223
+ const stamp = runtime.rt.lastNativeCompactAt;
224
+ const liveSid = runtime.rt.sessionId;
225
+ setTimeout(() => {
226
+ try {
227
+ if (runtime.rt.sessionId !== liveSid)
228
+ return; // session reset
229
+ const since2 = Date.now() - (runtime.rt.lastNativeCompactAt ?? 0);
230
+ if (runtime.rt.lastNativeCompactAt !== stamp && since2 < cooldownMs)
231
+ return;
232
+ if (piCompactWouldNoop(ctx))
233
+ return;
234
+ ctx.compact({
235
+ customInstructions: undefined,
236
+ }); // guardrails-allow PREVENT-PI-004: local ctx.compact() — no network; deferred + re-validated.
237
+ }
238
+ catch {
239
+ /* non-fatal */
240
+ }
241
+ }, 500);
242
+ }
243
+ else {
244
+ ctx.compact({
245
+ customInstructions: undefined,
246
+ }); // race-guarded by lastNativeCompactAt cooldown (ctx.compact returns void → not catchable; the cooldown prevents the call)
247
+ }
215
248
  return;
216
249
  }
217
250
  // S16 LIVE trim: collapse the compacted region to a summary + recent anchor.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * error-classifier.ts — S38.2 error classification for retry logic.
3
+ *
4
+ * Classifies turn-end error/stop signals into retry categories.
5
+ * Ordering matters: compaction-noop is matched BEFORE generic transient
6
+ * so a pi race / manual compact catch is never misclassified.
7
+ */
8
+ /** S38.2: classify a turn-end error/stop signal into a retry category.
9
+ *
10
+ * `length` is returned as null — S28 owns the max-output-token length stopReason
11
+ * exclusively (its agent_end nudge path is separate and must not be doubled).
12
+ *
13
+ * @param message the event.message (a pi AgentMessage) or an error string
14
+ * @returns 'transient' | 'permanent' | 'compaction-noop' | null (success/unknown)
15
+ */
16
+ export function classifyError(message) {
17
+ // Resolve a searchable text blob from a pi AgentMessage or raw string.
18
+ let text = '';
19
+ if (typeof message === 'string') {
20
+ text = message;
21
+ }
22
+ else if (message && typeof message === 'object') {
23
+ const m = message;
24
+ const sr = typeof m.stopReason === 'string' ? m.stopReason : '';
25
+ // S28 guard: length stopReason is handled exclusively by the S28 path.
26
+ if (sr === 'length')
27
+ return null;
28
+ // Success / normal tool flow — not an error, nothing to retry.
29
+ if (sr === 'stop' || sr === 'toolUse' || sr === 'tool_use')
30
+ return null;
31
+ const parts = [];
32
+ if (sr)
33
+ parts.push(sr);
34
+ const c = m.content;
35
+ if (typeof c === 'string')
36
+ parts.push(c);
37
+ else if (Array.isArray(c)) {
38
+ for (const b of c) {
39
+ if (b && typeof b === 'object' && 'text' in b) {
40
+ parts.push(String(b.text ?? ''));
41
+ }
42
+ }
43
+ }
44
+ if (m.error) {
45
+ // Extract message from error objects for pattern matching.
46
+ const err = m.error;
47
+ if (typeof err === 'string') {
48
+ parts.push(err);
49
+ }
50
+ else if (err && typeof err === 'object') {
51
+ const errObj = err;
52
+ if (typeof errObj.message === 'string') {
53
+ parts.push(errObj.message);
54
+ }
55
+ else {
56
+ parts.push(JSON.stringify(err));
57
+ }
58
+ }
59
+ }
60
+ // S38: detect mid-response errors where the stream died without a
61
+ // proper stopReason (empty/undefined) — this catches provider failures
62
+ // that cut off the response mid-stream, INCLUDING the case where the
63
+ // provider emitted partial content before dying (a truncated response
64
+ // with no stop reason is a mid-stream death, not a success).
65
+ // A genuine success ALWAYS carries stop/tool_use/toolUse (which
66
+ // short-circuit to null at the top), so any message reaching here with a
67
+ // falsy stopReason is a stream failure → retryable.
68
+ if (!sr) {
69
+ return 'transient';
70
+ }
71
+ text = parts.join(' ');
72
+ }
73
+ if (!text)
74
+ return null;
75
+ const s = text.toLowerCase();
76
+ // --- compaction-noop (ORDER FIRST: pi race / manual compact catch) ---
77
+ // FAIL-2026071701: these are NOT retryable — the compaction already
78
+ // succeeded via pi's native path; retrying would race again.
79
+ if (/already compacted/.test(s))
80
+ return 'compaction-noop';
81
+ if (/compaction failed/.test(s))
82
+ return 'compaction-noop';
83
+ if (/nothing to compact/.test(s))
84
+ return 'compaction-noop';
85
+ if (/auto[\s-]?compaction failed/.test(s))
86
+ return 'compaction-noop';
87
+ // --- transient (retryable) ---
88
+ if (s.includes('error') && !/\b(permanent|invalid request|malformed|bad request|auth|unauthorized|invalid (api )?key|permission)\b/.test(s)) {
89
+ return 'transient'; // generic pi stopReason 'error' / 'aborted'
90
+ }
91
+ if (s.includes('aborted'))
92
+ return 'transient';
93
+ if (/max(imum)? output token/.test(s))
94
+ return 'transient';
95
+ if (/rate[\s.-]?limit|429|too many requests/.test(s))
96
+ return 'transient';
97
+ if (/5\d\d|internal server|bad gateway|service unavailable/.test(s))
98
+ return 'transient';
99
+ if (/network|timeout|connection (lost|refused|reset)|stream (interrupted|closed|ended|failed)|disconnected/.test(s))
100
+ return 'transient';
101
+ // --- permanent (NOT retryable beyond 1) ---
102
+ if (/auth|unauthorized|invalid (api )?key|permission/.test(s))
103
+ return 'permanent';
104
+ if (/invalid request|malformed|bad request/.test(s))
105
+ return 'permanent';
106
+ // Unknown — do not retry (avoid busy-looping on an unclassified signal).
107
+ return null;
108
+ }
109
+ /** S38.2: exponential backoff for error-retry nudges (5s,10s,20s,30s,30s cap).
110
+ * count is 1-based (the retry about to fire). */
111
+ export function errorRetryBackoffMs(count) {
112
+ switch (count) {
113
+ case 1: return 5_000;
114
+ case 2: return 10_000;
115
+ case 3: return 20_000;
116
+ default: return 30_000; // cap from the 4th retry onward
117
+ }
118
+ }
@@ -16,3 +16,4 @@ export * from "./mega-events/agent-handlers.js";
16
16
  export * from "./mega-events/context-handler.js";
17
17
  export * from "./mega-events/compact-handlers.js";
18
18
  export * from "./mega-events/perf-handler.js";
19
+ export * from "./mega-events/error-classifier.js";
@@ -49,6 +49,9 @@ export class MegaRuntime {
49
49
  recallInjections: 0,
50
50
  cacheHitTokens: 0,
51
51
  lengthStopPending: false,
52
+ errorRetryCount: 0,
53
+ errorRetryUntil: 0,
54
+ consecutiveErrors: 0,
52
55
  };
53
56
  // v0.8.6 cache-stability: the cached live-trim view for the current
54
57
  // compaction epoch. Set after a fresh runCompact + computeLiveTrimCut, and
@@ -164,6 +167,11 @@ export class MegaRuntime {
164
167
  // getCachedGameState() snapshot re-queries the DB anyway.
165
168
  gameStateWatcher;
166
169
  gameStateWatchDir;
170
+ // P2: the last ExtensionContext handed to snapshot()/renderWidget(), stashed
171
+ // so the fs.watch game-state callback can force a widget re-render without
172
+ // a context event (cross-process dashboard edits while pi is idle). Cleared
173
+ // implicitly on construction (undefined → watcher skips until first snap).
174
+ lastWidgetCtx;
167
175
  /**
168
176
  * DIAG counters for the "team run doesn't relieve context" investigation.
169
177
  * Plain integers, incremented at the three compaction decision points. They
@@ -318,6 +326,8 @@ export class MegaRuntime {
318
326
  // ---- dashboard snapshot + widget ------------------------------------------
319
327
  /** Collect live state and write it to disk (+ paint the above-editor widget). */
320
328
  snapshot(ctx) {
329
+ if (ctx)
330
+ this.lastWidgetCtx = ctx;
321
331
  if (ctx)
322
332
  this.bindRepo(ctx.cwd);
323
333
  // v0.8.5: gate the expensive body (6 sync SQLite opens +
@@ -486,6 +496,16 @@ export class MegaRuntime {
486
496
  cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
487
497
  },
488
498
  model,
499
+ // S38.8: error-retry state for the dashboard "retries" tile. The field is
500
+ // declared on DashboardSnapshot (mega-dashboard.ts) and surfaced here so the
501
+ // dashboard can render live retry/circuit-breaker status alongside the event
502
+ // stream (which already carries per-retry events).
503
+ retries: {
504
+ errorRetryCount: this.rt.errorRetryCount,
505
+ consecutiveErrors: this.rt.consecutiveErrors,
506
+ maxConsecutiveErrors: this.config.maxConsecutiveErrors,
507
+ errorRetryHardStop: this.config.errorRetryHardStop,
508
+ },
489
509
  diag: {
490
510
  ctxFastGate: this.diagCtxFastGate,
491
511
  liveTrimFires: this.diagLiveTrimFires,
@@ -742,6 +762,9 @@ export class MegaRuntime {
742
762
  recallInjections: 0,
743
763
  cacheHitTokens: 0,
744
764
  lengthStopPending: false,
765
+ errorRetryCount: 0,
766
+ errorRetryUntil: 0,
767
+ consecutiveErrors: 0,
745
768
  };
746
769
  this.trimCache = null; // v0.8.6: never replay a stale trim into a new session
747
770
  this.statusKey = undefined;
@@ -892,6 +915,30 @@ export class MegaRuntime {
892
915
  if (typeof filename === "string" && filename.startsWith("sqlite.db")) {
893
916
  this.cachedGameState = undefined;
894
917
  this.gameStateBump++;
918
+ // P2: force a widget re-render so a dashboard-made theme/toggle/
919
+ // tui-mode change reflects in the live TUI immediately, even when
920
+ // pi is idle (no context event to drive snapshot()). Use the
921
+ // LIGHTWEIGHT refreshWidgetGameState() — NOT the full snapshot():
922
+ // snapshot() recomputes 6 sync SQLite opens + writes dashboard.json
923
+ // + writes to the store, and those store writes RETRIGGER this
924
+ // same fs.watch callback (it fires on every sqlite.db* write) →
925
+ // re-entrant thrash → 190s test timeout under mega-compact.test.js
926
+ // / mega-teamrun.test.js. The lightweight path re-reads ONLY the
927
+ // game_state row and patches the three game-mode fields on the
928
+ // existing widgetData, then re-registers the factory via
929
+ // renderWidget() — it writes nothing to the store or
930
+ // dashboard.json, so it cannot retrigger itself. Guard: skip until
931
+ // the first snapshot stashed a ctx (no widget registered yet →
932
+ // nothing to refresh). Non-fatal: next context event re-snapshots.
933
+ const ctx = this.lastWidgetCtx;
934
+ if (ctx) {
935
+ try {
936
+ this.refreshWidgetGameState(ctx);
937
+ }
938
+ catch {
939
+ /* non-fatal */
940
+ }
941
+ }
895
942
  }
896
943
  });
897
944
  this.gameStateWatchDir = this.currentStateDir;
@@ -971,6 +1018,33 @@ export class MegaRuntime {
971
1018
  }
972
1019
  return this.cachedGameState;
973
1020
  }
1021
+ /** P2 cross-process re-render: lightweight game-state refresh for the
1022
+ * fs.watch callback. Eviction of cachedGameState + gameStateBump++ happens
1023
+ * in the caller BEFORE this runs. Here we re-read ONLY the game_state row
1024
+ * via getCachedGameState() (one SELECT; the cache is already evicted) and
1025
+ * patch ONLY the three game-mode fields on the EXISTING widgetData, then
1026
+ * re-register the widget factory via renderWidget() so pi redraws next
1027
+ * frame.
1028
+ *
1029
+ * WHY a lightweight path: the full snapshot(ctx) recomputes 6 synchronous
1030
+ * SQLite opens + writeFileSync(dashboard.json) + store writes, and those
1031
+ * store writes RETRIGGER this same fs.watch callback → re-entrant thrash
1032
+ * (the watcher fires on every sqlite.db* write, including context-event
1033
+ * checkpoint writes) → 190s test timeout under mega-compact.test.js /
1034
+ * mega-teamrun.test.js. This path writes NOTHING to the store or
1035
+ * dashboard.json, so it cannot retrigger itself.
1036
+ *
1037
+ * Guard: no-op when widgetData is null (no snapshot has run yet → nothing
1038
+ * to patch) or ctx is undefined. Field values mirror snapshot() exactly. */
1039
+ refreshWidgetGameState(ctx) {
1040
+ if (!this.widgetData || !ctx)
1041
+ return;
1042
+ const gs = this.getCachedGameState();
1043
+ this.widgetData.gameMode = gs.game_mode_on;
1044
+ this.widgetData.theme = getTheme(gs.theme) ? gs.theme : "transparent";
1045
+ this.widgetData.tuiMode = gs.tui_display_mode;
1046
+ this.renderWidget(ctx);
1047
+ }
974
1048
  /** S31: evict the cached game-mode state so the next widget render re-reads
975
1049
  * the game_state row. Called by /mega-game after every setGameState() so
976
1050
  * the panel picks up theme/mode/toggle changes live. */
@@ -1044,6 +1118,14 @@ export class MegaRuntime {
1044
1118
  }
1045
1119
  // Phase 3 — recall/activity ticker ring buffer.
1046
1120
  pushTicker(text) {
1121
+ // P1: dedupe consecutive identical entries — skip the append when the
1122
+ // last entry's text matches, so a re-fired compact/recall/dedup event
1123
+ // doesn't flood the ring (keeps it at TICKER_MAX for real variety).
1124
+ // `at` is NOT refreshed on a skip (the original event time stands).
1125
+ if (this.ticker[this.ticker.length - 1]?.text === text) {
1126
+ this.lastActivityAt = Date.now();
1127
+ return;
1128
+ }
1047
1129
  this.ticker.push({ text, at: Date.now() });
1048
1130
  while (this.ticker.length > this.TICKER_MAX)
1049
1131
  this.ticker.shift();
@@ -110,6 +110,43 @@ describe("MegaRuntime setEffect lifecycle (v0.8.3)", () => {
110
110
  assert.equal(rt.activeEffect, null, "cleared once expired");
111
111
  });
112
112
  });
113
+ describe("MegaRuntime pushTicker dedupe (P1)", () => {
114
+ const dirs = [];
115
+ after(() => {
116
+ for (const d of dirs) {
117
+ try {
118
+ closeStore(d);
119
+ }
120
+ catch { /* */ }
121
+ }
122
+ delete process.env.MEGACOMPACT_STATE_DIR;
123
+ for (const d of dirs)
124
+ rmSync(d, { recursive: true, force: true });
125
+ });
126
+ it("pushTicker skips consecutive identical entries (keeps ring at capacity for variety)", () => {
127
+ const dir = freshDir();
128
+ dirs.push(dir);
129
+ process.env.MEGACOMPACT_STATE_DIR = dir;
130
+ const rt = new MegaRuntime(minimalConfig(dir));
131
+ rt.pushTicker("compact");
132
+ rt.pushTicker("compact"); // dedup — skipped
133
+ rt.pushTicker("compact"); // dedup — skipped
134
+ rt.pushTicker("recall");
135
+ assert.equal(rt.ticker.length, 2, "only 2 unique entries kept");
136
+ assert.equal(rt.ticker[0].text, "compact");
137
+ assert.equal(rt.ticker[1].text, "recall");
138
+ });
139
+ it("pushTicker keeps distinct entries and respects TICKER_MAX", () => {
140
+ const dir = freshDir();
141
+ dirs.push(dir);
142
+ process.env.MEGACOMPACT_STATE_DIR = dir;
143
+ const rt = new MegaRuntime(minimalConfig(dir));
144
+ for (let i = 0; i < rt.TICKER_MAX + 3; i++)
145
+ rt.pushTicker(`t${i}`);
146
+ assert.equal(rt.ticker.length, rt.TICKER_MAX, "capped at TICKER_MAX");
147
+ assert.equal(rt.ticker[rt.TICKER_MAX - 1].text, `t${rt.TICKER_MAX + 2}`);
148
+ });
149
+ });
113
150
  describe("MegaRuntime game-state cache (S31)", () => {
114
151
  const dirs = [];
115
152
  after(() => {
@@ -292,8 +292,12 @@ export function buildWidgetLines(wd, width, activeAgents) {
292
292
  lines.push(panelLine(` ${pulse}${wd.tierTrace}`, width, panelBg));
293
293
  }
294
294
  else if (wd.ticker.length > 0) {
295
- const step = Math.floor(Date.now() / 250);
296
- const idx = wd.ticker.length - 1 - (step % wd.ticker.length);
295
+ // P1: pin to the most-recent entry (no 250ms rotation). The old
296
+ // `step = floor(Date.now()/250)` re-picked the head every TUI frame,
297
+ // flipping the footer line on a 250·N ms cycle. Pinning makes the
298
+ // footer hold stable until a real state change (new pushTicker /
299
+ // tierTrace / pulsing transition). (+N more) + lastWhy are preserved.
300
+ const idx = wd.ticker.length - 1;
297
301
  const head = wd.ticker[idx].text;
298
302
  const why = wd.lastWhy ? ` ${C.gray}· ${wd.lastWhy}${C.reset}` : "";
299
303
  const more = wd.ticker.length > 1
@@ -181,6 +181,53 @@ describe("buildWidgetLines ambient border effect (v0.8.3)", () => {
181
181
  assert.ok(borders.some((b) => b.includes("\x1b[38;5;203m")), `flash-on phase uses red base 203`);
182
182
  });
183
183
  });
184
+ describe("buildWidgetLines footer stability (P1 — no 250ms rotation)", () => {
185
+ // P1: the L5 ticker branch used to re-pick the head text every 250ms via
186
+ // `step = floor(Date.now()/250)`, flipping the footer line on a 250·N ms
187
+ // cycle. After the fix it pins to the most-recent entry, so two renders of
188
+ // the SAME WidgetData at t and t+500ms (well past one rotation slot) must
189
+ // produce byte-identical footer lines. Proves the metronome is gone.
190
+ const stabBase = (overrides = {}) => baseWd({
191
+ theme: DEFAULT_THEME, tuiMode: "full", gameMode: false, level: 1, cachePct: 42, ...overrides,
192
+ });
193
+ it("ticker footer is byte-identical across simulated frames 500ms apart", () => {
194
+ const wd = stabBase({
195
+ ticker: [
196
+ { text: "first", at: 1000 },
197
+ { text: "second", at: 2000 },
198
+ { text: "third", at: 3000 },
199
+ ],
200
+ lastWhy: "because",
201
+ });
202
+ const realNow = Date.now;
203
+ try {
204
+ Date.now = () => 1_000_000; // t
205
+ const a = buildWidgetLines(wd, WIDTH, 0);
206
+ Date.now = () => 1_000_500; // t+500ms (would have rotated twice under the old code)
207
+ const b = buildWidgetLines(wd, WIDTH, 0);
208
+ assert.deepEqual(a, b, "footer byte-identical across 500ms with no state change");
209
+ // And the pinned head is the most-recent entry (third), not a rotation:
210
+ assert.ok(a.some((l) => l.includes("third")), "shows most-recent ticker entry");
211
+ assert.ok(a.some((l) => l.includes("(+2 more)")), "(+N more) suffix preserved");
212
+ assert.ok(a.some((l) => l.includes("because")), "lastWhy preserved");
213
+ }
214
+ finally {
215
+ Date.now = realNow;
216
+ }
217
+ });
218
+ it("footer still flips when the ticker actually changes (new pushTicker)", () => {
219
+ const realNow = Date.now;
220
+ try {
221
+ Date.now = () => 1_000_000;
222
+ const before = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }] }), WIDTH, 0);
223
+ const after = buildWidgetLines(stabBase({ ticker: [{ text: "a", at: 1 }, { text: "b", at: 2 }] }), WIDTH, 0);
224
+ assert.notDeepEqual(before, after, "footer changes when ticker grows");
225
+ }
226
+ finally {
227
+ Date.now = realNow;
228
+ }
229
+ });
230
+ });
184
231
  describe("buildWidgetLines achievement flare (S35)", () => {
185
232
  const achBase = (overrides = {}) => baseWd({
186
233
  theme: DEFAULT_THEME, tuiMode: "full", gameMode: true, level: 1, cachePct: 42, ...overrides,