pi-harness-runtime 0.9.6 → 0.9.10

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 +41 -10
  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 +272 -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 });
@@ -81,6 +128,33 @@ async function getCheckpointManager(): Promise<CheckpointManager> {
81
128
  ) as unknown as CheckpointManager;
82
129
  }
83
130
 
131
+ function isOutputLimitResumePromptMessage(message: {
132
+ role?: string;
133
+ content?: unknown;
134
+ }): boolean {
135
+ if (message.role !== "user") {
136
+ return false;
137
+ }
138
+ if (typeof message.content === "string") {
139
+ return message.content === OUTPUT_LIMIT_RESUME_PROMPT;
140
+ }
141
+ if (!Array.isArray(message.content)) {
142
+ return false;
143
+ }
144
+ return (
145
+ message.content
146
+ .filter(
147
+ (part): part is { type: "text"; text: string } =>
148
+ part &&
149
+ typeof part === "object" &&
150
+ (part as { type?: unknown }).type === "text" &&
151
+ typeof (part as { text?: unknown }).text === "string",
152
+ )
153
+ .map((part) => part.text)
154
+ .join("\n") === OUTPUT_LIMIT_RESUME_PROMPT
155
+ );
156
+ }
157
+
84
158
  export default function (pi: ExtensionAPI) {
85
159
  const tracker = new UsageTracker();
86
160
  const mirrorStore = new MirrorStore();
@@ -88,6 +162,10 @@ export default function (pi: ExtensionAPI) {
88
162
 
89
163
  // ─── Auto-track every assistant message ──────────────────────────────
90
164
  pi.on("message_end", async (event, ctx) => {
165
+ if (isOutputLimitResumePromptMessage(event.message)) {
166
+ pendingOutputLimitResumeAfterSettled = false;
167
+ return;
168
+ }
91
169
  if (event.message.role !== "assistant") return;
92
170
  const m = event.message as {
93
171
  role?: string;
@@ -111,9 +189,11 @@ export default function (pi: ExtensionAPI) {
111
189
  ) {
112
190
  outputLimitResumeAttempts += 1;
113
191
  pendingOutputLimitResumeAfterCompact = true;
192
+ pendingOutputLimitResumeAfterSettled = true;
114
193
  queueAutoResume("output-limit", OUTPUT_LIMIT_RESUME_PROMPT, "steer");
115
194
  } else if (m.stopReason === "stop") {
116
195
  outputLimitResumeAttempts = 0;
196
+ pendingOutputLimitResumeAfterSettled = false;
117
197
  }
118
198
 
119
199
  if (!m.usage) return;
@@ -126,6 +206,19 @@ export default function (pi: ExtensionAPI) {
126
206
  cache_write: m.usage.cacheWrite ?? 0,
127
207
  cost: m.usage.cost?.total ?? 0,
128
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
+ }
129
222
  });
130
223
 
131
224
  // ─── Smart quota fetch for MiniMax status ────────────────────────
@@ -135,7 +228,84 @@ export default function (pi: ExtensionAPI) {
135
228
  const quotaScraper = process.env.QUOTA_COOKIE_FILE
136
229
  ? new MiniMaxQuotaScraper({ cookieFile: process.env.QUOTA_COOKIE_FILE })
137
230
  : new MiniMaxQuotaScraper();
138
- 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
+
139
309
  let footerStatusCtx: {
140
310
  ui: { setStatus: (key: string, value: string) => void };
141
311
  } | null = null;
@@ -147,11 +317,21 @@ export default function (pi: ExtensionAPI) {
147
317
  let proactiveCompactCircuitReported = false;
148
318
  let outputLimitResumeAttempts = 0;
149
319
  let pendingOutputLimitResumeAfterCompact = false;
320
+ let pendingOutputLimitResumeAfterSettled = false;
150
321
 
151
- function writeMirrorRecord(record: MirrorRecord): void {
152
- 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 });
153
327
  if (footerStatusCtx) {
154
- refreshFooterStatus(footerStatusCtx, tracker, mirrorStore);
328
+ refreshFooterStatus(
329
+ footerStatusCtx,
330
+ tracker,
331
+ mirrorStore,
332
+ hasCookieSource,
333
+ () => lastActiveProvider,
334
+ );
155
335
  }
156
336
  }
157
337
 
@@ -167,7 +347,27 @@ export default function (pi: ExtensionAPI) {
167
347
  }
168
348
 
169
349
  function isMiniMaxModel(modelId: string | null | undefined): boolean {
170
- 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
+ }
171
371
  }
172
372
 
173
373
  function getMiniMaxUsageSince(sinceMs: number): {
@@ -246,7 +446,15 @@ export default function (pi: ExtensionAPI) {
246
446
  const profileRecord =
247
447
  await autoFetchQuotaFromBrowserProfile(suppressErrors);
248
448
  if (profileRecord) {
249
- 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
+ });
250
458
  return true;
251
459
  }
252
460
 
@@ -256,9 +464,9 @@ export default function (pi: ExtensionAPI) {
256
464
 
257
465
  try {
258
466
  const data = await quotaScraper.scrape();
259
- writeMirrorRecord({
467
+ writeMirrorRecord("minimax", {
260
468
  synced_at: data.scrapedAt,
261
- provider: "minimax",
469
+ source: "scrape",
262
470
  h5_used_pct: data.h5UsedPct,
263
471
  h5_resets_at: data.h5ResetsAt,
264
472
  weekly_used_pct: data.weeklyUsedPct,
@@ -339,8 +547,8 @@ export default function (pi: ExtensionAPI) {
339
547
  (await hasBrowserProfileAutoFetchSource());
340
548
  if (!autoFetchAvailable) {
341
549
  ctx.ui.notify(
342
- "Quota auto-fetch is unavailable. Set `QUOTA_COOKIE_FILE` or run `bun packages/auth/src/run-minimax-auth.ts auth` first.",
343
- "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",
344
552
  );
345
553
  return;
346
554
  }
@@ -418,7 +626,13 @@ export default function (pi: ExtensionAPI) {
418
626
  "info",
419
627
  );
420
628
  footerStatusCtx = ctx;
421
- refreshFooterStatus(ctx, tracker, mirrorStore);
629
+ refreshFooterStatus(
630
+ ctx,
631
+ tracker,
632
+ mirrorStore,
633
+ hasCookieSource,
634
+ () => lastActiveProvider,
635
+ );
422
636
  } catch (e) {
423
637
  ctx.ui.notify(`Failed to clear mirror: ${e}`, "error");
424
638
  }
@@ -673,23 +887,56 @@ export default function (pi: ExtensionAPI) {
673
887
  // ─── Footer status (persistent badge) ────────────────────────────────
674
888
  pi.on("session_start", (_event, ctx) => {
675
889
  footerStatusCtx = ctx;
676
- refreshFooterStatus(ctx, tracker, mirrorStore);
890
+ refreshFooterStatus(
891
+ ctx,
892
+ tracker,
893
+ mirrorStore,
894
+ hasCookieSource,
895
+ () => lastActiveProvider,
896
+ );
677
897
  });
678
898
 
679
899
  pi.on("turn_end", (_event, ctx) => {
900
+ noteActiveProvider(ctx.model?.id ?? null);
680
901
  footerStatusCtx = ctx;
681
- refreshFooterStatus(ctx, tracker, mirrorStore);
902
+ refreshFooterStatus(
903
+ ctx,
904
+ tracker,
905
+ mirrorStore,
906
+ hasCookieSource,
907
+ () => lastActiveProvider,
908
+ );
682
909
  void maybeAutoFetchQuota(ctx.model?.id ?? null);
683
910
  maybeTriggerProactiveCompact(ctx);
684
911
  });
685
912
 
913
+ pi.on("agent_end", () => {
914
+ setTimeout(() => {
915
+ if (!pendingOutputLimitResumeAfterSettled) {
916
+ return;
917
+ }
918
+ pendingOutputLimitResumeAfterSettled = false;
919
+ queueAutoResume(
920
+ "output-limit-settled",
921
+ OUTPUT_LIMIT_RESUME_PROMPT,
922
+ "followUp",
923
+ );
924
+ }, 0);
925
+ });
926
+
686
927
  pi.on("session_compact", (event, ctx) => {
687
928
  footerStatusCtx = ctx;
688
929
  proactiveCompactInFlight = false;
689
930
  lastProactiveCompactAt = Date.now();
690
931
  consecutiveCompactFailures = 0;
691
932
  proactiveCompactCircuitReported = false;
692
- refreshFooterStatus(ctx, tracker, mirrorStore);
933
+ refreshFooterStatus(
934
+ ctx,
935
+ tracker,
936
+ mirrorStore,
937
+ hasCookieSource,
938
+ () => lastActiveProvider,
939
+ );
693
940
 
694
941
  const forceOutputLimitResume = pendingOutputLimitResumeAfterCompact;
695
942
  pendingOutputLimitResumeAfterCompact = false;
@@ -784,13 +1031,22 @@ function refreshFooterStatus(
784
1031
  ctx: { ui: { setStatus: (key: string, value: string) => void } },
785
1032
  tracker: UsageTracker,
786
1033
  mirrorStore: MirrorStore,
1034
+ hasCookieSource: () => boolean,
1035
+ getActiveProvider: () => ProviderId | null = () => null,
787
1036
  ) {
788
1037
  const nowMs = Date.now();
789
1038
  const local = aggregateWindows(tracker.all());
790
- const mirror = mirrorStore.read();
1039
+ const provider = getActiveProvider();
1040
+ const mirror = provider ? mirrorStore.readProvider(provider) : null;
791
1041
  const freshness = mirrorStore.freshness(mirror, nowMs);
792
1042
  ctx.ui.setStatus(
793
1043
  "harness-runtime",
794
- buildFooterStatusValue(local, mirror, freshness),
1044
+ buildFooterStatusValue(
1045
+ local,
1046
+ mirror,
1047
+ freshness,
1048
+ hasCookieSource(),
1049
+ provider,
1050
+ ),
795
1051
  );
796
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.6",
3
+ "version": "0.9.10",
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.10",
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.10",
4
4
  "description": "pi-harness-runtime — auth",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",