pi-harness-runtime 0.9.7 → 0.9.11

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 (134) hide show
  1. package/CHANGELOG.md +24 -12
  2. package/README.md +42 -5
  3. package/footer-status.ts +108 -11
  4. package/harness/e2e/minimax-quota-scraper.ts +46 -4
  5. package/index.ts +224 -16
  6. package/mirror.ts +194 -18
  7. package/package.json +1 -1
  8. package/packages/a2a-adapter/package.json +1 -1
  9. package/packages/auth/package.json +1 -1
  10. package/packages/autonomous-refactor/package.json +1 -1
  11. package/packages/cache-strategy/package.json +1 -1
  12. package/packages/capability-registry/package.json +1 -1
  13. package/packages/checkpoint/package.json +1 -1
  14. package/packages/cli-plugin-sdk/package.json +1 -1
  15. package/packages/code-generation/package.json +1 -1
  16. package/packages/code-review/package.json +1 -1
  17. package/packages/codex-adapter/package.json +1 -1
  18. package/packages/context-compiler/package.json +1 -1
  19. package/packages/context-manager/package.json +1 -1
  20. package/packages/cookie-sanitizer/README.md +141 -0
  21. package/packages/cookie-sanitizer/dist/atomic-write.d.ts +38 -0
  22. package/packages/cookie-sanitizer/dist/atomic-write.d.ts.map +1 -0
  23. package/packages/cookie-sanitizer/dist/atomic-write.js +67 -0
  24. package/packages/cookie-sanitizer/dist/atomic-write.js.map +1 -0
  25. package/packages/cookie-sanitizer/dist/detect-format.d.ts +36 -0
  26. package/packages/cookie-sanitizer/dist/detect-format.d.ts.map +1 -0
  27. package/packages/cookie-sanitizer/dist/detect-format.js +88 -0
  28. package/packages/cookie-sanitizer/dist/detect-format.js.map +1 -0
  29. package/packages/cookie-sanitizer/dist/index.d.ts +41 -0
  30. package/packages/cookie-sanitizer/dist/index.d.ts.map +1 -0
  31. package/packages/cookie-sanitizer/dist/index.js +39 -0
  32. package/packages/cookie-sanitizer/dist/index.js.map +1 -0
  33. package/packages/cookie-sanitizer/dist/infer-provider.d.ts +29 -0
  34. package/packages/cookie-sanitizer/dist/infer-provider.d.ts.map +1 -0
  35. package/packages/cookie-sanitizer/dist/infer-provider.js +96 -0
  36. package/packages/cookie-sanitizer/dist/infer-provider.js.map +1 -0
  37. package/packages/cookie-sanitizer/dist/normalize.d.ts +26 -0
  38. package/packages/cookie-sanitizer/dist/normalize.d.ts.map +1 -0
  39. package/packages/cookie-sanitizer/dist/normalize.js +75 -0
  40. package/packages/cookie-sanitizer/dist/normalize.js.map +1 -0
  41. package/packages/cookie-sanitizer/dist/parse-json.d.ts +45 -0
  42. package/packages/cookie-sanitizer/dist/parse-json.d.ts.map +1 -0
  43. package/packages/cookie-sanitizer/dist/parse-json.js +108 -0
  44. package/packages/cookie-sanitizer/dist/parse-json.js.map +1 -0
  45. package/packages/cookie-sanitizer/dist/parse-netscape.d.ts +43 -0
  46. package/packages/cookie-sanitizer/dist/parse-netscape.d.ts.map +1 -0
  47. package/packages/cookie-sanitizer/dist/parse-netscape.js +99 -0
  48. package/packages/cookie-sanitizer/dist/parse-netscape.js.map +1 -0
  49. package/packages/cookie-sanitizer/dist/sync.d.ts +62 -0
  50. package/packages/cookie-sanitizer/dist/sync.d.ts.map +1 -0
  51. package/packages/cookie-sanitizer/dist/sync.js +266 -0
  52. package/packages/cookie-sanitizer/dist/sync.js.map +1 -0
  53. package/packages/cookie-sanitizer/dist/types.d.ts +100 -0
  54. package/packages/cookie-sanitizer/dist/types.d.ts.map +1 -0
  55. package/packages/cookie-sanitizer/dist/types.js +13 -0
  56. package/packages/cookie-sanitizer/dist/types.js.map +1 -0
  57. package/packages/cookie-sanitizer/dist/watcher.d.ts +51 -0
  58. package/packages/cookie-sanitizer/dist/watcher.d.ts.map +1 -0
  59. package/packages/cookie-sanitizer/dist/watcher.js +148 -0
  60. package/packages/cookie-sanitizer/dist/watcher.js.map +1 -0
  61. package/packages/cookie-sanitizer/package.json +27 -0
  62. package/packages/cookie-sanitizer/src/atomic-write.ts +79 -0
  63. package/packages/cookie-sanitizer/src/detect-format.ts +102 -0
  64. package/packages/cookie-sanitizer/src/index.ts +89 -0
  65. package/packages/cookie-sanitizer/src/infer-provider.ts +103 -0
  66. package/packages/cookie-sanitizer/src/normalize.ts +92 -0
  67. package/packages/cookie-sanitizer/src/parse-json.ts +122 -0
  68. package/packages/cookie-sanitizer/src/parse-netscape.ts +118 -0
  69. package/packages/cookie-sanitizer/src/sync.ts +304 -0
  70. package/packages/cookie-sanitizer/src/types.ts +101 -0
  71. package/packages/cookie-sanitizer/src/watcher.ts +180 -0
  72. package/packages/cookie-sanitizer/test/infer.test.ts +110 -0
  73. package/packages/cookie-sanitizer/test/normalize.test.ts +113 -0
  74. package/packages/cookie-sanitizer/test/parse.test.ts +204 -0
  75. package/packages/cookie-sanitizer/test/sync.test.ts +231 -0
  76. package/packages/cookie-sanitizer/test/watcher.test.ts +27 -0
  77. package/packages/cookie-sanitizer/tsconfig.json +8 -0
  78. package/packages/cost-optimizer/package.json +1 -1
  79. package/packages/django-plugin/package.json +1 -1
  80. package/packages/doc-generator/package.json +1 -1
  81. package/packages/evaluation-engine/package.json +1 -1
  82. package/packages/evaluation-runner/package.json +1 -1
  83. package/packages/experience-replay/package.json +1 -1
  84. package/packages/feedback-collector/package.json +1 -1
  85. package/packages/framework-detector/package.json +1 -1
  86. package/packages/framework-plugin-sdk/package.json +1 -1
  87. package/packages/frappe-plugin/package.json +1 -1
  88. package/packages/generic-web-plugin/package.json +1 -1
  89. package/packages/health-monitor/package.json +1 -1
  90. package/packages/laravel-plugin/package.json +1 -1
  91. package/packages/learning-engine/package.json +1 -1
  92. package/packages/mcp-adapter/package.json +1 -1
  93. package/packages/memory-engine/package.json +1 -1
  94. package/packages/model-registry/package.json +1 -1
  95. package/packages/nextjs-plugin/package.json +1 -1
  96. package/packages/notification/package.json +1 -1
  97. package/packages/observability/package.json +1 -1
  98. package/packages/performance-optimizer/package.json +1 -1
  99. package/packages/project-analyzer/package.json +1 -1
  100. package/packages/prompt-compiler/package.json +1 -1
  101. package/packages/prompt-versioning/package.json +1 -1
  102. package/packages/provider-adapter-sdk/package.json +1 -1
  103. package/packages/provider-router/package.json +1 -1
  104. package/packages/provider-selector/package.json +1 -1
  105. package/packages/providers/dist/index.d.ts +1 -0
  106. package/packages/providers/dist/index.d.ts.map +1 -1
  107. package/packages/providers/dist/index.js +1 -0
  108. package/packages/providers/dist/index.js.map +1 -1
  109. package/packages/providers/dist/provider-id.d.ts +39 -0
  110. package/packages/providers/dist/provider-id.d.ts.map +1 -0
  111. package/packages/providers/dist/provider-id.js +96 -0
  112. package/packages/providers/dist/provider-id.js.map +1 -0
  113. package/packages/providers/package.json +1 -1
  114. package/packages/providers/src/index.ts +1 -0
  115. package/packages/providers/src/provider-id.ts +110 -0
  116. package/packages/quota-manager/package.json +1 -1
  117. package/packages/rate-limiter/package.json +1 -1
  118. package/packages/react-vite-plugin/package.json +1 -1
  119. package/packages/release-manager/package.json +1 -1
  120. package/packages/requirement-compiler/package.json +1 -1
  121. package/packages/runtime/package.json +1 -1
  122. package/packages/scheduler/package.json +1 -1
  123. package/packages/session/package.json +1 -1
  124. package/packages/shared-context/package.json +1 -1
  125. package/packages/skill-registry/package.json +1 -1
  126. package/packages/sprint-planner/package.json +1 -1
  127. package/packages/task-compiler/package.json +1 -1
  128. package/packages/test-data-generator/package.json +1 -1
  129. package/packages/test-generator/package.json +1 -1
  130. package/packages/token-estimation/package.json +1 -1
  131. package/packages/token-optimizer/package.json +1 -1
  132. package/packages/tui/package.json +1 -1
  133. package/packages/types/package.json +1 -1
  134. package/packages/worktree/package.json +1 -1
package/index.ts CHANGED
@@ -23,6 +23,20 @@ import { UsageTracker } from "./tracker.ts";
23
23
  import { MirrorStore, type MirrorRecord } from "./mirror.ts";
24
24
  import { MiniMaxQuotaScraper } from "./harness/e2e/minimax-quota-scraper.js";
25
25
  import { parseMiniMaxQuotaText } from "./harness/e2e/minimax-quota-parser.js";
26
+ import {
27
+ CookieWatcher,
28
+ DEFAULT_DROP_DIR as COOKIE_DROP_DIR,
29
+ hasAnyCookieSource as sanitizerHasAnyCookieSource,
30
+ } from "./packages/cookie-sanitizer/src/index.ts";
31
+ import {
32
+ providerFromModelId,
33
+ type ProviderId,
34
+ } from "./packages/providers/src/provider-id.ts";
35
+ import {
36
+ TUIUsageMonitor,
37
+ type TUIUsageSignal,
38
+ } from "./packages/quota-manager/src/tui-usage-monitor.ts";
39
+ import { QuotaManager } from "./packages/quota-manager/src/quota-manager.ts";
26
40
  import { buildFooterStatusValue } from "./footer-status.ts";
27
41
  import {
28
42
  MAX_PROACTIVE_COMPACT_FAILURES,
@@ -66,6 +80,39 @@ interface HarnessSession {
66
80
 
67
81
  let currentSession: HarnessSession | null = null;
68
82
 
83
+ /**
84
+ * Safely extract a text view from an LLM message. Used to feed the TUI
85
+ * quota-signal extractor. Handles string content, array-of-parts content
86
+ * (with `text` fields), and falls back to a stringified JSON for unknown
87
+ * shapes. Never throws.
88
+ */
89
+ function readMessageText(message: unknown): string {
90
+ try {
91
+ if (!message || typeof message !== "object") return "";
92
+ const m = message as { role?: unknown; content?: unknown };
93
+ if (typeof m.content === "string") return m.content;
94
+ if (Array.isArray(m.content)) {
95
+ const parts: string[] = [];
96
+ for (const p of m.content) {
97
+ if (!p) continue;
98
+ if (typeof p === "string") {
99
+ parts.push(p);
100
+ } else if (typeof p === "object") {
101
+ const obj = p as { text?: unknown; content?: unknown };
102
+ if (typeof obj.text === "string") parts.push(obj.text);
103
+ else if (typeof obj.content === "string") parts.push(obj.content);
104
+ }
105
+ }
106
+ return parts.join("\n");
107
+ }
108
+ // Fallback: best-effort stringification. Never include cookie-shaped
109
+ // data; we only stringify LLM message shapes which are JSON-safe.
110
+ return JSON.stringify(message).slice(0, 8000);
111
+ } catch {
112
+ return "";
113
+ }
114
+ }
115
+
69
116
  function ensureHarnessDir() {
70
117
  if (!existsSync(HARNESS_ROOT_DIR)) {
71
118
  mkdirSync(HARNESS_ROOT_DIR, { recursive: true });
@@ -159,6 +206,19 @@ export default function (pi: ExtensionAPI) {
159
206
  cache_write: m.usage.cacheWrite ?? 0,
160
207
  cost: m.usage.cost?.total ?? 0,
161
208
  });
209
+
210
+ // Feed TUI quota-signal extractor with the assistant message text.
211
+ // Best-effort — never throws. Coalesces provider quota notifications
212
+ // (e.g. "OpenAI: context length exceeded, reset in 3 hr 27 min") into
213
+ // the per-provider mirror via `tuiMonitor`.
214
+ try {
215
+ const text = readMessageText(event.message);
216
+ if (text) {
217
+ tuiMonitor.processMessage(text);
218
+ }
219
+ } catch {
220
+ // best-effort
221
+ }
162
222
  });
163
223
 
164
224
  // ─── Smart quota fetch for MiniMax status ────────────────────────
@@ -168,7 +228,84 @@ export default function (pi: ExtensionAPI) {
168
228
  const quotaScraper = process.env.QUOTA_COOKIE_FILE
169
229
  ? new MiniMaxQuotaScraper({ cookieFile: process.env.QUOTA_COOKIE_FILE })
170
230
  : new MiniMaxQuotaScraper();
171
- const cookieQuotaAutoFetchAvailable = quotaScraper.hasCookieFile();
231
+
232
+ // ─── Cookie sanitizer integration ────────────────────────────────────
233
+ // The drop folder is the user-facing, forgiving input. The canonical
234
+ // cache (`~/.config/minimax-cookies.txt`) is the runtime-owned,
235
+ // normalized output that `MiniMaxQuotaScraper` reads. Either being
236
+ // present enables scraping.
237
+ const cookieDropDir = COOKIE_DROP_DIR;
238
+ const cookieCachePath = join(homedir(), ".config", "minimax-cookies.txt");
239
+
240
+ const hasCookieSource = (): boolean => {
241
+ try {
242
+ if (existsSync(cookieCachePath)) return true;
243
+ } catch {
244
+ // ignore
245
+ }
246
+ try {
247
+ return sanitizerHasAnyCookieSource(cookieDropDir);
248
+ } catch {
249
+ return false;
250
+ }
251
+ };
252
+
253
+ const cookieQuotaAutoFetchAvailable = hasCookieSource();
254
+
255
+ // ─── TUI quota signal plumbing (OpenAI / GLM / Anthropic / OpenRouter) ──
256
+ // The TUIUsageMonitor parses provider quota-exhaustion messages from pi's
257
+ // TUI / message stream and emits signals we write to per-provider mirror
258
+ // entries. For providers that don't expose a continuous usage API this is
259
+ // the only path to surface data in the footer.
260
+ const quotaManager = new QuotaManager();
261
+ const tuiMonitor = new TUIUsageMonitor({ quotaManager });
262
+
263
+ tuiMonitor.on("signal", (signal: TUIUsageSignal) => {
264
+ try {
265
+ writeMirrorRecord(signal.provider as ProviderId, {
266
+ synced_at: signal.timestamp,
267
+ source: "tui-signal",
268
+ exhausted: signal.exhausted,
269
+ limitType: signal.limitType,
270
+ remainingPct: signal.remainingPct,
271
+ resets_at: signal.resetsAt,
272
+ });
273
+ } catch (e) {
274
+ console.error(
275
+ "[pi-harness] tui-signal write failed:",
276
+ e instanceof Error ? e.message : String(e),
277
+ );
278
+ }
279
+ });
280
+
281
+ // Live watcher — sanitises on every change in the drop folder.
282
+ const cookieWatcher = new CookieWatcher({
283
+ dropDir: cookieDropDir,
284
+ syncOptions: { cachePath: cookieCachePath },
285
+ onEvent: (event) => {
286
+ if (event.kind === "sync-error" || event.kind === "watcher-error") {
287
+ console.error(
288
+ "[pi-harness] cookie-sanitizer:",
289
+ "message" in event ? event.message : "",
290
+ );
291
+ }
292
+ // A successful sync means the canonical cache is fresh; the
293
+ // next autoFetchQuota() should pick it up immediately. Reset
294
+ // the rate-limit so we don't wait 15 min for the first scrape.
295
+ if (event.kind === "sync-ok") {
296
+ lastQuotaAutoFetchAt = 0;
297
+ }
298
+ },
299
+ });
300
+ try {
301
+ cookieWatcher.start();
302
+ } catch (e) {
303
+ console.error(
304
+ "[pi-harness] cookie-sanitizer watcher failed to start:",
305
+ e instanceof Error ? e.message : String(e),
306
+ );
307
+ }
308
+
172
309
  let footerStatusCtx: {
173
310
  ui: { setStatus: (key: string, value: string) => void };
174
311
  } | null = null;
@@ -182,10 +319,19 @@ export default function (pi: ExtensionAPI) {
182
319
  let pendingOutputLimitResumeAfterCompact = false;
183
320
  let pendingOutputLimitResumeAfterSettled = false;
184
321
 
185
- function writeMirrorRecord(record: MirrorRecord): void {
186
- mirrorStore.write(record);
322
+ function writeMirrorRecord(
323
+ provider: ProviderId,
324
+ record: Omit<import("./mirror.js").ProviderMirrorRecord, "provider">,
325
+ ): void {
326
+ mirrorStore.writeProvider(provider, { ...record, provider });
187
327
  if (footerStatusCtx) {
188
- refreshFooterStatus(footerStatusCtx, tracker, mirrorStore);
328
+ refreshFooterStatus(
329
+ footerStatusCtx,
330
+ tracker,
331
+ mirrorStore,
332
+ hasCookieSource,
333
+ () => lastActiveProvider,
334
+ );
189
335
  }
190
336
  }
191
337
 
@@ -201,7 +347,27 @@ export default function (pi: ExtensionAPI) {
201
347
  }
202
348
 
203
349
  function isMiniMaxModel(modelId: string | null | undefined): boolean {
204
- return /minimax/i.test(modelId ?? "");
350
+ return providerFromModelId(modelId) === "minimax";
351
+ }
352
+
353
+ /** Active provider for the current/last-seen model. Updated by event handlers. */
354
+ let lastActiveProvider: ProviderId | null = null;
355
+
356
+ /** Set the active provider; triggers a footer refresh. */
357
+ function noteActiveProvider(modelId: string | null | undefined): void {
358
+ const p = providerFromModelId(modelId);
359
+ if (p !== lastActiveProvider) {
360
+ lastActiveProvider = p;
361
+ if (footerStatusCtx) {
362
+ refreshFooterStatus(
363
+ footerStatusCtx,
364
+ tracker,
365
+ mirrorStore,
366
+ hasCookieSource,
367
+ () => lastActiveProvider,
368
+ );
369
+ }
370
+ }
205
371
  }
206
372
 
207
373
  function getMiniMaxUsageSince(sinceMs: number): {
@@ -280,7 +446,15 @@ export default function (pi: ExtensionAPI) {
280
446
  const profileRecord =
281
447
  await autoFetchQuotaFromBrowserProfile(suppressErrors);
282
448
  if (profileRecord) {
283
- writeMirrorRecord(profileRecord);
449
+ writeMirrorRecord("minimax", {
450
+ synced_at: profileRecord.synced_at,
451
+ source: "scrape",
452
+ model: profileRecord.model,
453
+ h5_used_pct: profileRecord.h5_used_pct,
454
+ h5_resets_at: profileRecord.h5_resets_at,
455
+ weekly_used_pct: profileRecord.weekly_used_pct,
456
+ weekly_resets_at: profileRecord.weekly_resets_at,
457
+ });
284
458
  return true;
285
459
  }
286
460
 
@@ -290,9 +464,9 @@ export default function (pi: ExtensionAPI) {
290
464
 
291
465
  try {
292
466
  const data = await quotaScraper.scrape();
293
- writeMirrorRecord({
467
+ writeMirrorRecord("minimax", {
294
468
  synced_at: data.scrapedAt,
295
- provider: "minimax",
469
+ source: "scrape",
296
470
  h5_used_pct: data.h5UsedPct,
297
471
  h5_resets_at: data.h5ResetsAt,
298
472
  weekly_used_pct: data.weeklyUsedPct,
@@ -373,8 +547,8 @@ export default function (pi: ExtensionAPI) {
373
547
  (await hasBrowserProfileAutoFetchSource());
374
548
  if (!autoFetchAvailable) {
375
549
  ctx.ui.notify(
376
- "Quota auto-fetch is unavailable. Set `QUOTA_COOKIE_FILE` or run `bun packages/auth/src/run-minimax-auth.ts auth` first.",
377
- "info",
550
+ "MiniMax cookies not found. Drop any cookie file (Netscape or EditThisCookie JSON) into ~/.pi-harness-runtime/cookies/ — the runtime normalizes it for you. Or run `bun packages/auth/src/run-minimax-auth.ts auth`.",
551
+ "warning",
378
552
  );
379
553
  return;
380
554
  }
@@ -452,7 +626,13 @@ export default function (pi: ExtensionAPI) {
452
626
  "info",
453
627
  );
454
628
  footerStatusCtx = ctx;
455
- refreshFooterStatus(ctx, tracker, mirrorStore);
629
+ refreshFooterStatus(
630
+ ctx,
631
+ tracker,
632
+ mirrorStore,
633
+ hasCookieSource,
634
+ () => lastActiveProvider,
635
+ );
456
636
  } catch (e) {
457
637
  ctx.ui.notify(`Failed to clear mirror: ${e}`, "error");
458
638
  }
@@ -707,12 +887,25 @@ export default function (pi: ExtensionAPI) {
707
887
  // ─── Footer status (persistent badge) ────────────────────────────────
708
888
  pi.on("session_start", (_event, ctx) => {
709
889
  footerStatusCtx = ctx;
710
- refreshFooterStatus(ctx, tracker, mirrorStore);
890
+ refreshFooterStatus(
891
+ ctx,
892
+ tracker,
893
+ mirrorStore,
894
+ hasCookieSource,
895
+ () => lastActiveProvider,
896
+ );
711
897
  });
712
898
 
713
899
  pi.on("turn_end", (_event, ctx) => {
900
+ noteActiveProvider(ctx.model?.id ?? null);
714
901
  footerStatusCtx = ctx;
715
- refreshFooterStatus(ctx, tracker, mirrorStore);
902
+ refreshFooterStatus(
903
+ ctx,
904
+ tracker,
905
+ mirrorStore,
906
+ hasCookieSource,
907
+ () => lastActiveProvider,
908
+ );
716
909
  void maybeAutoFetchQuota(ctx.model?.id ?? null);
717
910
  maybeTriggerProactiveCompact(ctx);
718
911
  });
@@ -737,7 +930,13 @@ export default function (pi: ExtensionAPI) {
737
930
  lastProactiveCompactAt = Date.now();
738
931
  consecutiveCompactFailures = 0;
739
932
  proactiveCompactCircuitReported = false;
740
- refreshFooterStatus(ctx, tracker, mirrorStore);
933
+ refreshFooterStatus(
934
+ ctx,
935
+ tracker,
936
+ mirrorStore,
937
+ hasCookieSource,
938
+ () => lastActiveProvider,
939
+ );
741
940
 
742
941
  const forceOutputLimitResume = pendingOutputLimitResumeAfterCompact;
743
942
  pendingOutputLimitResumeAfterCompact = false;
@@ -832,13 +1031,22 @@ function refreshFooterStatus(
832
1031
  ctx: { ui: { setStatus: (key: string, value: string) => void } },
833
1032
  tracker: UsageTracker,
834
1033
  mirrorStore: MirrorStore,
1034
+ hasCookieSource: () => boolean,
1035
+ getActiveProvider: () => ProviderId | null = () => null,
835
1036
  ) {
836
1037
  const nowMs = Date.now();
837
1038
  const local = aggregateWindows(tracker.all());
838
- const mirror = mirrorStore.read();
1039
+ const provider = getActiveProvider();
1040
+ const mirror = provider ? mirrorStore.readProvider(provider) : null;
839
1041
  const freshness = mirrorStore.freshness(mirror, nowMs);
840
1042
  ctx.ui.setStatus(
841
1043
  "harness-runtime",
842
- buildFooterStatusValue(local, mirror, freshness),
1044
+ buildFooterStatusValue(
1045
+ local,
1046
+ mirror,
1047
+ freshness,
1048
+ hasCookieSource(),
1049
+ provider,
1050
+ ),
843
1051
  );
844
1052
  }
package/mirror.ts CHANGED
@@ -1,29 +1,132 @@
1
1
  /**
2
- * MirrorStore — cached provider quota snapshot.
2
+ * MirrorStore — cached per-provider quota snapshot.
3
3
  *
4
- * Auto refresh fetches MiniMax quota data from the web console and writes it to
5
- * ~/.pi/usage-status/mirror.json so footer status and `/usage` can render the
6
- * latest provider-side 5h/weekly usage.
4
+ * Auto refresh writes per-provider data to ~/.pi/usage-status/mirror.json so
5
+ * the footer status and `/usage` can render the latest provider-side
6
+ * usage. Local tracking counts OUR usage; the mirror counts what's
7
+ * currently knowable from the provider (continuous for MiniMax via
8
+ * scrape; one-shot TUI signal for OpenAI / GLM / etc.).
7
9
  *
8
- * Local tracking counts OUR usage only; the mirror counts TOTAL quota usage
9
- * across all clients visible in the provider console.
10
+ * Shape (per-provider map):
11
+ *
12
+ * ```jsonc
13
+ * {
14
+ * "minimax": {
15
+ * "synced_at": "2026-07-23T06:20:04Z",
16
+ * "source": "scrape" | "tui-signal",
17
+ * "h5_used_pct": 15,
18
+ * "h5_resets_at": "3 hr 39 min",
19
+ * "weekly_used_pct": 21,
20
+ * "weekly_resets_at": "3 days 17 hr 39 min"
21
+ * },
22
+ * "openai": { ... }
23
+ * }
24
+ * ```
25
+ *
26
+ * Back-compat: legacy single-row files are read and upgraded in place
27
+ * on the first `readAll()` call after upgrade. Old rows that match a
28
+ * known provider (have a `provider` field) are placed under that key.
29
+ *
30
+ * Models live here: `~/.pi/usage-status/mirror.json`.
10
31
  */
11
32
 
12
33
  import { getMirrorPath, readJson, writeJson } from "./cli.ts";
13
34
 
35
+ /** Where a piece of data came from. */
36
+ export type MirrorSource = "scrape" | "tui-signal" | "manual";
37
+
38
+ /** Per-provider mirror record. */
39
+ export interface ProviderMirrorRecord {
40
+ /** ISO-8601 UTC timestamp when this row was last updated. */
41
+ synced_at: string;
42
+ /** Provider id (e.g. "minimax", "openai", "glm"). Must match the key. */
43
+ provider: string;
44
+ /** Source of the data — used for diagnostics. */
45
+ source: MirrorSource;
46
+ /** Optional model id the data was captured for. */
47
+ model?: string;
48
+ /** 5h window used percent (0-100). Provider-reported when available. */
49
+ h5_used_pct?: number;
50
+ /** When the 5h window resets — human-readable or ISO. */
51
+ h5_resets_at?: string;
52
+ /** Weekly used percent. */
53
+ weekly_used_pct?: number;
54
+ /** When the weekly window resets. */
55
+ weekly_resets_at?: string;
56
+ /** Set when a limit was hit and we have the exhaustion signal. */
57
+ exhausted?: boolean;
58
+ /** Limit type from TUI signal: tokens, context_window, rate_limit, unknown. */
59
+ limitType?: "tokens" | "context_window" | "rate_limit" | "unknown";
60
+ /** Remaining pct (0-100) at exhaustion — 0 means exactly exhausted. */
61
+ remainingPct?: number;
62
+ /** ISO-8601 reset timestamp from TUI signal, if absolute. */
63
+ resets_at?: string;
64
+ }
65
+
66
+ /** Map of provider id → its mirror record. */
67
+ export type PerProviderMirror = Record<string, ProviderMirrorRecord>;
68
+
69
+ /**
70
+ * Legacy single-row shape — accepted on read and migrated forward on
71
+ * the first `readAll()` call after upgrade. Writers should use the
72
+ * per-provider shape via `writeProvider(provider, record)`.
73
+ */
14
74
  export interface MirrorRecord {
15
- synced_at: string; // ISO 8601 UTC
16
- provider: string; // e.g. "minimax"
17
- model?: string; // optional, e.g. "minimax/MiniMax-M3"
18
- h5_used_pct?: number; // 0-100
19
- h5_resets_at?: string; // ISO 8601 UTC — provider-reported
20
- weekly_used_pct?: number; // 0-100
21
- weekly_resets_at?: string; // ISO 8601 UTC — provider-reported
75
+ synced_at: string;
76
+ provider?: string;
77
+ model?: string;
78
+ h5_used_pct?: number;
79
+ h5_resets_at?: string;
80
+ weekly_used_pct?: number;
81
+ weekly_resets_at?: string;
22
82
  }
23
83
 
84
+ /** Known provider ids used as keys in the per-provider map. */
85
+ export type KnownProviderId =
86
+ | "minimax"
87
+ | "openai"
88
+ | "openai-codex"
89
+ | "glm"
90
+ | "anthropic"
91
+ | "openrouter"
92
+ | (string & {}); // allow extension
93
+
24
94
  const STALE_WARN_MS = 30 * 60 * 1000; // 30 min → orange
25
95
  const STALE_ERROR_MS = 2 * 60 * 60 * 1000; // 2 h → red
26
96
 
97
+ /**
98
+ * Detect whether a raw read looks like a legacy single-row shape
99
+ * (i.e. not yet the per-provider map). Used to gate the upgrade path.
100
+ */
101
+ function isLegacyShape(raw: unknown): raw is MirrorRecord & {
102
+ provider: string;
103
+ [key: string]: unknown;
104
+ } {
105
+ if (!raw || typeof raw !== "object") return false;
106
+ const obj = raw as Record<string, unknown>;
107
+ if ("provider" in obj && typeof obj.provider === "string") {
108
+ // Legacy single-row shape: has flat provider string, no map-of-records structure.
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ /** Convert a legacy single-row record to per-provider shape. */
115
+ function upgradeLegacyRecord(legacy: MirrorRecord): PerProviderMirror {
116
+ const provider = legacy.provider ?? "minimax";
117
+ const rec: ProviderMirrorRecord = {
118
+ synced_at: legacy.synced_at ?? new Date().toISOString(),
119
+ provider,
120
+ source: "scrape", // legacy was always scrape
121
+ model: legacy.model,
122
+ h5_used_pct: legacy.h5_used_pct,
123
+ h5_resets_at: legacy.h5_resets_at,
124
+ weekly_used_pct: legacy.weekly_used_pct,
125
+ weekly_resets_at: legacy.weekly_resets_at,
126
+ };
127
+ return { [provider]: rec };
128
+ }
129
+
27
130
  export class MirrorStore {
28
131
  private path: string;
29
132
 
@@ -31,21 +134,88 @@ export class MirrorStore {
31
134
  this.path = path;
32
135
  }
33
136
 
34
- /** Read the mirror record. Returns null if missing/corrupted. */
137
+ /** Read the mirror record. LEGACY: returns whatever is in the file. */
35
138
  read(): MirrorRecord | null {
36
139
  const raw = readJson(this.path);
37
140
  if (!raw || typeof raw !== "object") return null;
38
141
  return raw as MirrorRecord;
39
142
  }
40
143
 
41
- /** Write a new mirror record (overwrites). */
144
+ /**
145
+ * Read the entire per-provider mirror. Auto-migrates legacy shape
146
+ * on first read after upgrade. Returns null if the file is missing
147
+ * or corrupted.
148
+ */
149
+ readAll(): PerProviderMirror | null {
150
+ const raw = readJson(this.path);
151
+ if (!raw || typeof raw !== "object") return null;
152
+
153
+ if (isLegacyShape(raw)) {
154
+ const upgraded = upgradeLegacyRecord(raw as MirrorRecord);
155
+ // Persist the upgrade so subsequent reads are fast.
156
+ try {
157
+ writeJson(this.path, upgraded);
158
+ } catch {
159
+ // best-effort
160
+ }
161
+ return upgraded;
162
+ }
163
+
164
+ // Already per-provider shape.
165
+ const out: PerProviderMirror = {};
166
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
167
+ if (v && typeof v === "object") {
168
+ out[k] = v as ProviderMirrorRecord;
169
+ }
170
+ }
171
+ return Object.keys(out).length > 0 ? out : null;
172
+ }
173
+
174
+ /** Read a single provider's record. Returns null if missing. */
175
+ readProvider(provider: string): ProviderMirrorRecord | null {
176
+ const all = this.readAll();
177
+ if (!all) return null;
178
+ return all[provider] ?? null;
179
+ }
180
+
181
+ /** Write a single provider's record (overwrites just that key). */
182
+ writeProvider(provider: string, record: ProviderMirrorRecord): void {
183
+ const all = this.readAll() ?? {};
184
+ all[provider] = record;
185
+ writeJson(this.path, all);
186
+ }
187
+
188
+ /** Write a new mirror record (overwrites). LEGACY: deprecated, use writeProvider. */
42
189
  write(record: MirrorRecord): void {
190
+ // If the caller passes a record that already has a provider key,
191
+ // route through writeProvider so we keep the per-provider shape.
192
+ if (
193
+ record &&
194
+ typeof record === "object" &&
195
+ "provider" in record &&
196
+ typeof (record as { provider: unknown }).provider === "string"
197
+ ) {
198
+ const r = record as MirrorRecord;
199
+ const provider = (r.provider as string) ?? "minimax";
200
+ const prev = this.readProvider(provider);
201
+ this.writeProvider(provider, {
202
+ synced_at: r.synced_at ?? new Date().toISOString(),
203
+ provider,
204
+ source: prev?.source ?? "scrape",
205
+ model: r.model ?? prev?.model,
206
+ h5_used_pct: r.h5_used_pct ?? prev?.h5_used_pct,
207
+ h5_resets_at: r.h5_resets_at ?? prev?.h5_resets_at,
208
+ weekly_used_pct: r.weekly_used_pct ?? prev?.weekly_used_pct,
209
+ weekly_resets_at: r.weekly_resets_at ?? prev?.weekly_resets_at,
210
+ });
211
+ return;
212
+ }
43
213
  writeJson(this.path, record);
44
214
  }
45
215
 
46
216
  /** Returns "fresh" | "stale" | "expired" based on age. */
47
217
  freshness(
48
- record: MirrorRecord | null,
218
+ record: ProviderMirrorRecord | MirrorRecord | null,
49
219
  nowMs: number,
50
220
  ): "fresh" | "stale" | "expired" | "missing" {
51
221
  if (!record || !record.synced_at) return "missing";
@@ -58,12 +228,18 @@ export class MirrorStore {
58
228
  }
59
229
 
60
230
  /** True if data is too stale to trust (> 2 hours old). */
61
- isExpired(record: MirrorRecord | null, nowMs: number): boolean {
231
+ isExpired(
232
+ record: ProviderMirrorRecord | MirrorRecord | null,
233
+ nowMs: number,
234
+ ): boolean {
62
235
  return this.freshness(record, nowMs) === "expired";
63
236
  }
64
237
 
65
238
  /** Convenience: human-readable age like "5 min ago" or "1 d 2 h ago". */
66
- ageString(record: MirrorRecord | null, nowMs: number): string {
239
+ ageString(
240
+ record: ProviderMirrorRecord | MirrorRecord | null,
241
+ nowMs: number,
242
+ ): string {
67
243
  if (!record || !record.synced_at) return "never";
68
244
  const syncedMs = Date.parse(record.synced_at);
69
245
  if (isNaN(syncedMs)) return "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-harness-runtime",
3
- "version": "0.9.7",
3
+ "version": "0.9.11",
4
4
  "description": "[BETA] Codex-style /usage status + autonomous coding harness for pi. Not production ready — expect breaking changes.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/a2a-adapter",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "description": "Google Agent-to-Agent (A2A) protocol adapter for pi-harness",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/auth",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "description": "pi-harness-runtime — auth",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/autonomous-refactor",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "description": "Autonomous refactoring engine",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/cache-strategy",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "description": "Smart caching with TTL and invalidation strategies (RFC-0022)",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/capability-registry",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "description": "Capability Registry for pi-harness-runtime — tracks model capabilities and enables capability-based routing",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/checkpoint",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "description": "Enhanced checkpoint system with incremental snapshots, diff-based storage, and recovery strategies",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/cli-plugin-sdk",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "description": "CLI Plugin SDK for pi-harness — install, list, remove, update, invoke plugins",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/code-generation",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "description": "Code generation pipeline for pi-harness-runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi/code-review",
3
- "version": "0.9.6",
3
+ "version": "0.9.11",
4
4
  "description": "Automated code review engine for pi-harness-runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",