pi-harness-runtime 0.10.13 → 0.10.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,1121 @@
1
+ /**
2
+ * pi-harness-runtime — Codex-style /usage status for pi.
3
+ *
4
+ * Slash commands:
5
+ * /usage — show full status (model, local tracking, provider mirror)
6
+ * /usage today — focused: this 5h + today (UTC)
7
+ * /usage week — focused: this week + lifetime
8
+ * /usage reset — clear mirror (forces a fresh auto fetch)
9
+ *
10
+ * Auto-tracks every assistant message via the message_end event.
11
+ * Stores data in ~/.pi/usage-status/ (override with PI_USAGE_DIR for testing).
12
+ *
13
+ * Runs directly from Bun.
14
+ */
15
+ import { UsageTracker } from "./tracker.ts";
16
+ import { MirrorStore } from "./mirror.ts";
17
+ import { MiniMaxQuotaScraper } from "./harness/e2e/minimax-quota-scraper.js";
18
+ import { OpenAIQuotaScraper } from "./harness/e2e/openai-quota-scraper.js";
19
+ import { parseMiniMaxQuotaText } from "./harness/e2e/minimax-quota-parser.js";
20
+ import { CookieWatcher, DEFAULT_DROP_DIR as COOKIE_DROP_DIR, hasAnyCookieSource as sanitizerHasAnyCookieSource, } from "./packages/cookie-sanitizer/src/index.ts";
21
+ import { providerFromModelId, } from "./packages/providers/src/provider-id.ts";
22
+ import { TUIUsageMonitor, } from "./packages/quota-manager/src/tui-usage-monitor.ts";
23
+ import { QuotaManager } from "./packages/quota-manager/src/quota-manager.ts";
24
+ import { buildFooterStatusValue } from "./footer-status.ts";
25
+ import { registerGithubLoginCommand } from "./packages/clipboard/src/github-login.js";
26
+ import { MAX_PROACTIVE_COMPACT_FAILURES, OUTPUT_LIMIT_RESUME_PROMPT, PROACTIVE_COMPACT_COOLDOWN_MS, shouldQueueOutputLimitResume, shouldQueuePostCompactionResume, shouldTriggerProactiveCompact, } from "./proactive-compact.ts";
27
+ import { aggregateWindows } from "./windows.ts";
28
+ import { renderStatus } from "./renderer.ts";
29
+ import { JobStateMachine, } from "./harness/job-state-machine.ts";
30
+ import { createTaskGraphManager, } from "./harness/task-graph.js";
31
+ import { MasterPlanner } from "./harness/master-planner.ts";
32
+ import { RepairEngine } from "./harness/repair-engine.ts";
33
+ import { createBlackboard, } from "./harness/blackboard.ts";
34
+ import { scheduleAutoResume, cancelAutoResume } from "./harness/index.js";
35
+ // --- todo-bd-sync: Two-way sync between rpiv-todo and bd --------------------
36
+ // Lazy import - only loads when packages/todo-bd-sync exists
37
+ async function initTodoBdSync(pi) {
38
+ try {
39
+ const mod = await import("./packages/todo-bd-sync/src/extension.js");
40
+ mod.registerTodoBdSync(pi, { debug: false });
41
+ }
42
+ catch {
43
+ // todo-bd-sync not available
44
+ }
45
+ }
46
+ // --- config-capture: Auto-detect and document API configuration -------------
47
+ // Lazy import - only loads when packages/config-capture exists
48
+ async function initConfigCapture(pi) {
49
+ try {
50
+ const mod = await import("./packages/config-capture/src/index.js");
51
+ mod.registerConfigCapture(pi, { debug: false });
52
+ }
53
+ catch {
54
+ // config-capture not available
55
+ }
56
+ }
57
+ // --- write-review: Two-agent write with review loop --------------------------
58
+ // Lazy import - only loads when packages/write-review exists
59
+ async function initWriteReview(pi) {
60
+ try {
61
+ const mod = await import("./packages/write-review/src/index.js");
62
+ mod.injectWriterInstructions(pi);
63
+ }
64
+ catch {
65
+ // write-review not available
66
+ }
67
+ }
68
+ import { homedir } from "node:os";
69
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
70
+ import { join } from "node:path";
71
+ // --- Debug log → file instead of TUI ---------------------------------
72
+ const DEBUG_LOG_DIR = join(homedir(), ".pi", "harness-logs");
73
+ const DEBUG_LOG_PATH = join(DEBUG_LOG_DIR, "harness-debug.log");
74
+ try {
75
+ if (!existsSync(DEBUG_LOG_DIR))
76
+ mkdirSync(DEBUG_LOG_DIR, { recursive: true });
77
+ }
78
+ catch {
79
+ /* non-fatal */
80
+ }
81
+ // Write harness runtime logs to file only (NOT to TUI stdout)
82
+ function _debugLog(...args) {
83
+ try {
84
+ const line = new Date().toISOString() +
85
+ " " +
86
+ args
87
+ .map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
88
+ .join(" ");
89
+ appendFileSync(DEBUG_LOG_PATH, line + "\n");
90
+ }
91
+ catch {
92
+ // non-fatal
93
+ }
94
+ }
95
+ // --- Selective console override — harness DEBUG → file only --------
96
+ // Real errors (no [DEBUG prefix) still print to TUI so you notice problems.
97
+ const _origLog = console.log.bind(console);
98
+ const _origError = console.error.bind(console);
99
+ console.log = (...args) => {
100
+ _origLog(...args);
101
+ _debugLog(...args);
102
+ };
103
+ console.error = (...args) => {
104
+ const first = String(args[0] ?? "");
105
+ if (first.startsWith("[DEBUG")) {
106
+ _debugLog(...args);
107
+ }
108
+ else {
109
+ _origError(...args);
110
+ _debugLog(...args);
111
+ }
112
+ };
113
+ // --- Harness Runtime State --------------------------------------------
114
+ const HARNESS_ROOT_DIR = join(homedir(), ".pi", "harness");
115
+ let currentSession = null;
116
+ /**
117
+ * Safely extract a text view from an LLM message. Used to feed the TUI
118
+ * quota-signal extractor. Handles string content, array-of-parts content
119
+ * (with `text` fields), and falls back to a stringified JSON for unknown
120
+ * shapes. Never throws.
121
+ */
122
+ function readMessageText(message) {
123
+ try {
124
+ if (!message || typeof message !== "object")
125
+ return "";
126
+ const m = message;
127
+ if (typeof m.content === "string")
128
+ return m.content;
129
+ if (Array.isArray(m.content)) {
130
+ const parts = [];
131
+ for (const p of m.content) {
132
+ if (!p)
133
+ continue;
134
+ if (typeof p === "string") {
135
+ parts.push(p);
136
+ }
137
+ else if (typeof p === "object") {
138
+ const obj = p;
139
+ if (typeof obj.text === "string")
140
+ parts.push(obj.text);
141
+ else if (typeof obj.content === "string")
142
+ parts.push(obj.content);
143
+ }
144
+ }
145
+ return parts.join("\n");
146
+ }
147
+ // Fallback: best-effort stringification. Never include cookie-shaped
148
+ // data; we only stringify LLM message shapes which are JSON-safe.
149
+ return JSON.stringify(message).slice(0, 8000);
150
+ }
151
+ catch {
152
+ return "";
153
+ }
154
+ }
155
+ function ensureHarnessDir() {
156
+ if (!existsSync(HARNESS_ROOT_DIR)) {
157
+ mkdirSync(HARNESS_ROOT_DIR, { recursive: true });
158
+ }
159
+ // Also ensure the shared cookie drop folder exists
160
+ const cookieDropDir = join(homedir(), ".pi-harness-runtime", "cookies");
161
+ if (!existsSync(cookieDropDir)) {
162
+ mkdirSync(cookieDropDir, { recursive: true });
163
+ }
164
+ }
165
+ async function getCheckpointManager() {
166
+ const { JsonCheckpointManager } = await import("./packages/checkpoint/src/checkpoint-manager.ts");
167
+ return new JsonCheckpointManager(HARNESS_ROOT_DIR);
168
+ }
169
+ function isOutputLimitResumePromptMessage(message) {
170
+ if (message.role !== "user") {
171
+ return false;
172
+ }
173
+ if (typeof message.content === "string") {
174
+ return message.content === OUTPUT_LIMIT_RESUME_PROMPT;
175
+ }
176
+ if (!Array.isArray(message.content)) {
177
+ return false;
178
+ }
179
+ return (message.content
180
+ .filter((part) => part &&
181
+ typeof part === "object" &&
182
+ part.type === "text" &&
183
+ typeof part.text === "string")
184
+ .map((part) => part.text)
185
+ .join("\n") === OUTPUT_LIMIT_RESUME_PROMPT);
186
+ }
187
+ export default function (pi) {
188
+ const tracker = new UsageTracker();
189
+ const mirrorStore = new MirrorStore();
190
+ ensureHarnessDir();
191
+ // --- todo-bd-sync: Initialize two-way sync with bd ---------------
192
+ // Start async init but don't await - runs in background
193
+ void initTodoBdSync(pi);
194
+ // --- config-capture: Auto-detect API config and document it -----------
195
+ void initConfigCapture(pi);
196
+ // --- write-review: Two-agent write with review loop ----------------------
197
+ void initWriteReview(pi);
198
+ // --- Auto-Invoke rpiv-todo via System Prompt ------------------------
199
+ // This makes the todo overlay ALWAYS activate at session start
200
+ const AUTO_TODO_INVOKE_HINT = `
201
+
202
+ You have access to a todo tool that shows a persistent task overlay.
203
+ ALWAYS use it at the START of every session to capture user requirements as tasks.
204
+ When the user gives you a multi-step task:
205
+ 1. Immediately create todo items for each step using the todo tool
206
+ 2. Keep tasks updated - mark in_progress when working, completed when done
207
+ 3. When a task is completed, continue to the next or ask the user
208
+
209
+ The todo overlay persists and helps track progress across your conversation.
210
+ `;
211
+ const COMMIT_BUILD_CHECKLIST = `
212
+
213
+ BEFORE committing code or triggering builds, ALWAYS check:
214
+ 1. Run 'bd ready' to see pending tasks
215
+ 2. Mark completed tasks with 'bd close <id> --reason "Done"'
216
+ 3. Acknowledge pending tasks before proceeding
217
+ 4. Never commit/build without acknowledging pending todos
218
+ `;
219
+ const WRITE_REVIEW_HINT = `
220
+
221
+ WRITER-REVIEWER WORKFLOW:
222
+ When working on a feature from {project}/wiki/* or {project}/.write-review/:
223
+ 1. Read the prompt/task file to understand requirements
224
+ 2. Write clean, complete code
225
+ 3. Mark task "in_progress" in todos
226
+ 4. When code is ready, trigger review with \`{done} bd create "Review: <task>" -t review -p 1 && bd close <id> --reason "Approved"\`
227
+ 5. If reviewer requests changes, update code and re-review
228
+ 6. Never skip review on non-trivial features
229
+ `;
230
+ const DOCKER_CLEANUP_HINT = `
231
+
232
+ DOCKER CLEANUP WORKFLOW:
233
+ AFTER running any Docker build command (docker build, docker compose build, docker compose up --build):
234
+ 1. ALWAYS run \`docker builder prune -f\` to clean up build cache
235
+ 2. This saves disk space - Docker build cache grows fast
236
+ 3. Example: After \`docker compose up --build\`, run \`docker builder prune -f\`
237
+ 4. For aggressive cleanup: \`docker builder prune -a -f\` (removes ALL unused cache)
238
+ `;
239
+ let firstAgentStart = true;
240
+ pi.on("before_agent_start", async (event) => {
241
+ if (firstAgentStart) {
242
+ event.systemPrompt += AUTO_TODO_INVOKE_HINT;
243
+ event.systemPrompt += COMMIT_BUILD_CHECKLIST;
244
+ event.systemPrompt += WRITE_REVIEW_HINT;
245
+ event.systemPrompt += DOCKER_CLEANUP_HINT;
246
+ firstAgentStart = false;
247
+ }
248
+ });
249
+ // --- Auto-Todo Reminder on Build Commands ---------------------------
250
+ // Detect build commands and remind agent to update todos
251
+ const BUILD_COMMANDS = [
252
+ "docker build",
253
+ "docker compose build",
254
+ "docker compose up",
255
+ "npm run build",
256
+ "yarn build",
257
+ "pnpm build",
258
+ "bun run build",
259
+ "make build",
260
+ "gradle build",
261
+ "dotnet build",
262
+ "cargo build",
263
+ "go build",
264
+ "bench build",
265
+ ];
266
+ const TODO_BUILD_REMINDER = `
267
+
268
+ IMPORTANT - TODO UPDATE REMINDER:
269
+ Before running a build, ensure you update the current task status:
270
+ 1. Mark the task as in_progress with bd update <id> --status in_progress
271
+ 2. After build succeeds, update the task: bd close <id> --reason "Done" or bd update <id> --status pending
272
+
273
+ Run \`bd ready\` to see current tasks.
274
+ `;
275
+ // Detect build commands and append todo reminder to their output
276
+ pi.on("tool_execution_end", async (event) => {
277
+ const toolName = event.toolName;
278
+ if (toolName !== "bash")
279
+ return;
280
+ const result = event.result;
281
+ if (!result)
282
+ return;
283
+ const content = result.content ?? "";
284
+ // Check if this is a build command
285
+ const isBuildCommand = BUILD_COMMANDS.some((cmd) => content.toLowerCase().includes(cmd.toLowerCase()));
286
+ if (isBuildCommand && !content.includes("bd ready") && !content.includes("TODO UPDATE")) {
287
+ // Append todo reminder to build output
288
+ result.content = content + TODO_BUILD_REMINDER;
289
+ }
290
+ });
291
+ // --- Auto-track every assistant message ------------------------------
292
+ pi.on("message_end", async (event, ctx) => {
293
+ if (isOutputLimitResumePromptMessage(event.message)) {
294
+ pendingOutputLimitResumeAfterSettled = false;
295
+ return;
296
+ }
297
+ if (event.message.role !== "assistant")
298
+ return;
299
+ // DEBUG: Log model ID
300
+ const m = event.message;
301
+ if (shouldQueueOutputLimitResume(m, outputLimitResumeAttempts, ctx.hasPendingMessages())) {
302
+ outputLimitResumeAttempts += 1;
303
+ pendingOutputLimitResumeAfterCompact = true;
304
+ pendingOutputLimitResumeAfterSettled = true;
305
+ queueAutoResume("output-limit", OUTPUT_LIMIT_RESUME_PROMPT, "steer");
306
+ }
307
+ else if (m.stopReason === "stop") {
308
+ outputLimitResumeAttempts = 0;
309
+ pendingOutputLimitResumeAfterSettled = false;
310
+ }
311
+ if (!m.usage)
312
+ return;
313
+ tracker.append({
314
+ ts: Date.now(),
315
+ model: ctx.model?.id ?? "unknown",
316
+ input: m.usage.input ?? 0,
317
+ output: m.usage.output ?? 0,
318
+ cache_read: m.usage.cacheRead ?? 0,
319
+ cache_write: m.usage.cacheWrite ?? 0,
320
+ cost: m.usage.cost?.total ?? 0,
321
+ });
322
+ // Feed TUI quota-signal extractor with the assistant message text.
323
+ // Best-effort — never throws. Coalesces provider quota notifications
324
+ // (e.g. "OpenAI: context length exceeded, reset in 3 hr 27 min") into
325
+ // the per-provider mirror via `tuiMonitor`.
326
+ try {
327
+ const text = readMessageText(event.message);
328
+ if (text) {
329
+ // "[DEBUG message_end] Processing TUI text (first 200 chars):",
330
+ // text.substring(0, 200),
331
+ // );
332
+ tuiMonitor.processMessage(text);
333
+ }
334
+ }
335
+ catch (e) {
336
+ // console.error("[DEBUG message_end] TUI processMessage error:", e);
337
+ }
338
+ });
339
+ // --- Smart quota fetch for MiniMax status ------------------------
340
+ const MINIMAX_REFRESH_MIN_INTERVAL_MS = 15 * 60 * 1000;
341
+ const MINIMAX_REFRESH_TOKEN_THRESHOLD = 200_000;
342
+ const MINIMAX_REFRESH_REQUEST_THRESHOLD = 12;
343
+ const quotaScraper = process.env.QUOTA_COOKIE_FILE
344
+ ? new MiniMaxQuotaScraper({ cookieFile: process.env.QUOTA_COOKIE_FILE })
345
+ : new MiniMaxQuotaScraper();
346
+ // --- Smart quota fetch for OpenAI status -------------------------
347
+ const OPENAI_REFRESH_MIN_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
348
+ const _openaiQuotaScraper = new OpenAIQuotaScraper({ quiet: true });
349
+ let lastOpenAIQuotaFetchAt = 0;
350
+ // --- Cookie sanitizer integration ------------------------------------
351
+ // The drop folder is the user-facing, forgiving input. The canonical
352
+ // cache (`~/.config/minimax-cookies.txt`) is the runtime-owned,
353
+ // normalized output that `MiniMaxQuotaScraper` reads. Either being
354
+ // present enables scraping.
355
+ const cookieDropDir = COOKIE_DROP_DIR;
356
+ const cookieCachePath = join(homedir(), ".config", "minimax-cookies.txt");
357
+ const hasCookieSource = () => {
358
+ try {
359
+ if (existsSync(cookieCachePath))
360
+ return true;
361
+ }
362
+ catch {
363
+ // ignore
364
+ }
365
+ try {
366
+ return sanitizerHasAnyCookieSource(cookieDropDir);
367
+ }
368
+ catch {
369
+ return false;
370
+ }
371
+ };
372
+ const cookieQuotaAutoFetchAvailable = hasCookieSource();
373
+ // --- TUI quota signal plumbing (OpenAI / GLM / Anthropic / OpenRouter) --
374
+ // The TUIUsageMonitor parses provider quota-exhaustion messages from pi's
375
+ // TUI / message stream and emits signals we write to per-provider mirror
376
+ // entries. For providers that don't expose a continuous usage API this is
377
+ // the only path to surface data in the footer.
378
+ const quotaManager = new QuotaManager();
379
+ const tuiMonitor = new TUIUsageMonitor({ quotaManager });
380
+ tuiMonitor.on("signal", (signal) => {
381
+ // "[DEBUG tuiMonitor signal] Received signal:",
382
+ // JSON.stringify(signal),
383
+ // );
384
+ try {
385
+ writeMirrorRecord(signal.provider, {
386
+ synced_at: signal.timestamp,
387
+ source: "tui-signal",
388
+ exhausted: signal.exhausted,
389
+ limitType: signal.limitType,
390
+ remainingPct: signal.remainingPct,
391
+ resets_at: signal.resetsAt,
392
+ });
393
+ }
394
+ catch (e) {
395
+ console.error("[pi-harness] tui-signal write failed:", e instanceof Error ? e.message : String(e));
396
+ }
397
+ });
398
+ let lastQuotaAutoFetchAt = 0;
399
+ // Live watcher — sanitises on every change in the drop folder.
400
+ const cookieWatcher = new CookieWatcher({
401
+ dropDir: cookieDropDir,
402
+ syncOptions: { cachePath: cookieCachePath, providerHint: "minimax" },
403
+ onEvent: (event) => {
404
+ if (event.kind === "sync-error" || event.kind === "watcher-error") {
405
+ const msg = "message" in event ? event.message : "";
406
+ if (msg.includes("ENOSPC")) {
407
+ console.error("[pi-harness] cookie-sanitizer: ENOSPC — inotify watchers exhausted.\n" +
408
+ " Fix (run once as sudo):\n" +
409
+ " echo fs.inotify.max_user_watches=524288 | sudo tee /etc/sysctl.d/99-watch.conf\n" +
410
+ " sudo sysctl --system");
411
+ }
412
+ else {
413
+ console.error("[pi-harness] cookie-sanitizer:", msg);
414
+ }
415
+ }
416
+ // A successful sync means the canonical cache is fresh; the
417
+ // next autoFetchQuota() should pick it up immediately. Reset
418
+ // the rate-limit so we don't wait 15 min for the first scrape.
419
+ if (event.kind === "sync-ok") {
420
+ lastQuotaAutoFetchAt = 0;
421
+ }
422
+ },
423
+ });
424
+ try {
425
+ cookieWatcher.start();
426
+ // Sync existing drop-folder cookies now (ignoreInitial: true means
427
+ // the watcher won't do this automatically on startup).
428
+ cookieWatcher.triggerNow();
429
+ }
430
+ catch (e) {
431
+ const msg = e instanceof Error ? e.message : String(e);
432
+ if (msg.includes("ENOSPC")) {
433
+ console.error("[pi-harness] cookie-sanitizer watcher: ENOSPC — " +
434
+ "inotify watcher limit reached.\n" +
435
+ "Fix (once, as root):\n" +
436
+ " echo fs.inotify.max_user_watches=524288 | sudo tee /etc/sysctl.d/99-watch.conf\n" +
437
+ " sudo sysctl --system\n" +
438
+ "Drop folder sync still works — polling will resume automatically.");
439
+ }
440
+ else {
441
+ console.error("[pi-harness] cookie-sanitizer watcher failed to start:", msg);
442
+ }
443
+ }
444
+ let footerStatusCtx = null;
445
+ let quotaAutoFetchInFlight = false;
446
+ let proactiveCompactInFlight = false;
447
+ let lastProactiveCompactAt = 0;
448
+ let consecutiveCompactFailures = 0;
449
+ let proactiveCompactCircuitReported = false;
450
+ let outputLimitResumeAttempts = 0;
451
+ let pendingOutputLimitResumeAfterCompact = false;
452
+ let pendingOutputLimitResumeAfterSettled = false;
453
+ // --- Context-usage escalating warning tiers -----------------------------
454
+ // Amber at 75%, red at 85%. No notify above 90% (proactive compact handles it).
455
+ // Per-session dedup: only notify when crossing INTO a new higher tier.
456
+ const TIER_WARNING = 0.75; // amber
457
+ const TIER_ERROR = 0.85; // red
458
+ let lastNotifiedTier = "none";
459
+ function maybeNotifyContextUsage(ctx) {
460
+ const usage = ctx.getContextUsage();
461
+ const pct = usage?.percent;
462
+ if (pct === null || pct === undefined)
463
+ return;
464
+ if (pct >= 0.9)
465
+ return; // proactive compact handles 90%+
466
+ const tier = pct >= TIER_ERROR ? "error" : pct >= TIER_WARNING ? "warning" : "none";
467
+ // Only notify when crossing into a strictly higher tier
468
+ if (tier === "none") {
469
+ lastNotifiedTier = "none";
470
+ return;
471
+ }
472
+ if (tier === "warning" &&
473
+ lastNotifiedTier !== "warning" &&
474
+ lastNotifiedTier !== "error") {
475
+ lastNotifiedTier = "warning";
476
+ ctx.ui.notify(`Context at ${Math.round(pct * 100)}% — consider condensing`, "info");
477
+ return;
478
+ }
479
+ if (tier === "error" && lastNotifiedTier !== "error") {
480
+ lastNotifiedTier = "error";
481
+ ctx.ui.notify(`Context at ${Math.round(pct * 100)}% — approaching limit`, "warning");
482
+ return;
483
+ }
484
+ }
485
+ function writeMirrorRecord(provider, record) {
486
+ // "[DEBUG writeMirrorRecord] Writing record for provider:",
487
+ // provider,
488
+ // "record:",
489
+ // JSON.stringify(record),
490
+ // );
491
+ mirrorStore.writeProvider(provider, { ...record, provider });
492
+ if (footerStatusCtx) {
493
+ refreshFooterStatus(footerStatusCtx, tracker, mirrorStore, hasCookieSource, () => lastActiveProvider);
494
+ }
495
+ }
496
+ async function hasBrowserProfileAutoFetchSource() {
497
+ try {
498
+ const { getLiveSessionPath, getStatusPath } = await import("./packages/auth/src/minimax-browser-auth.ts");
499
+ return existsSync(getLiveSessionPath()) || existsSync(getStatusPath());
500
+ }
501
+ catch {
502
+ return false;
503
+ }
504
+ }
505
+ function isMiniMaxModel(modelId) {
506
+ return providerFromModelId(modelId) === "minimax";
507
+ }
508
+ /** Active provider for the current/last-seen model. Updated by event handlers. */
509
+ let lastActiveProvider = null;
510
+ /** Set the active provider; triggers a footer refresh. */
511
+ function noteActiveProvider(modelId) {
512
+ const p = providerFromModelId(modelId);
513
+ // "[DEBUG noteActiveProvider] modelId =",
514
+ // modelId,
515
+ // "-> provider =",
516
+ // p,
517
+ // );
518
+ if (p !== lastActiveProvider) {
519
+ lastActiveProvider = p;
520
+ if (footerStatusCtx) {
521
+ refreshFooterStatus(footerStatusCtx, tracker, mirrorStore, hasCookieSource, () => lastActiveProvider);
522
+ }
523
+ }
524
+ }
525
+ function getMiniMaxUsageSince(sinceMs) {
526
+ const records = tracker
527
+ .since(sinceMs)
528
+ .filter((record) => isMiniMaxModel(record.model));
529
+ return {
530
+ tokens: records.reduce((sum, record) => sum + record.input + record.output, 0),
531
+ requests: records.length,
532
+ };
533
+ }
534
+ async function autoFetchQuotaFromBrowserProfile(suppressErrors = false) {
535
+ if (!(await hasBrowserProfileAutoFetchSource())) {
536
+ return null;
537
+ }
538
+ try {
539
+ const { scrapeWithExistingProfile } = await import("./packages/auth/src/minimax-browser-auth.ts");
540
+ const status = await scrapeWithExistingProfile({ quiet: true });
541
+ if (status.page_url.includes("unified-login") ||
542
+ status.page_url.includes("login")) {
543
+ return null;
544
+ }
545
+ const rawText = status.usage_lines?.join("\n")?.trim() ||
546
+ status.detected_text_sample?.trim() ||
547
+ "";
548
+ if (!rawText) {
549
+ return null;
550
+ }
551
+ const parsed = parseMiniMaxQuotaText(rawText);
552
+ if (parsed.h5UsedPct === undefined &&
553
+ parsed.weeklyUsedPct === undefined) {
554
+ return null;
555
+ }
556
+ return {
557
+ synced_at: status.checked_at,
558
+ provider: "minimax",
559
+ h5_used_pct: parsed.h5UsedPct,
560
+ h5_resets_at: parsed.h5ResetsAt,
561
+ weekly_used_pct: parsed.weeklyUsedPct,
562
+ weekly_resets_at: parsed.weeklyResetsAt,
563
+ };
564
+ }
565
+ catch (error) {
566
+ if (!suppressErrors) {
567
+ console.error("[pi-harness] Browser-profile quota fetch skipped:", error instanceof Error ? error.message : String(error));
568
+ }
569
+ return null;
570
+ }
571
+ }
572
+ async function autoFetchQuota(options) {
573
+ const suppressErrors = options?.suppressErrors === true;
574
+ const profileRecord = await autoFetchQuotaFromBrowserProfile(suppressErrors);
575
+ if (profileRecord) {
576
+ writeMirrorRecord("minimax", {
577
+ synced_at: profileRecord.synced_at,
578
+ source: "scrape",
579
+ model: profileRecord.model,
580
+ h5_used_pct: profileRecord.h5_used_pct,
581
+ h5_resets_at: profileRecord.h5_resets_at,
582
+ weekly_used_pct: profileRecord.weekly_used_pct,
583
+ weekly_resets_at: profileRecord.weekly_resets_at,
584
+ });
585
+ return true;
586
+ }
587
+ if (!cookieQuotaAutoFetchAvailable) {
588
+ return false;
589
+ }
590
+ try {
591
+ const data = await quotaScraper.scrape();
592
+ writeMirrorRecord("minimax", {
593
+ synced_at: data.scrapedAt,
594
+ source: "scrape",
595
+ h5_used_pct: data.h5UsedPct,
596
+ h5_resets_at: data.h5ResetsAt,
597
+ h5_resets_at_epoch: data.h5ResetsAtEpoch,
598
+ weekly_used_pct: data.weeklyUsedPct,
599
+ weekly_resets_at: data.weeklyResetsAt,
600
+ weekly_resets_at_epoch: data.weeklyResetsAtEpoch,
601
+ });
602
+ return true;
603
+ }
604
+ catch (error) {
605
+ if (!suppressErrors) {
606
+ console.error("[pi-harness] Quota auto-fetch skipped:", error instanceof Error ? error.message : String(error));
607
+ }
608
+ return false;
609
+ }
610
+ }
611
+ /**
612
+ * Auto-fetch OpenAI quota via ChatGPT Codex analytics.
613
+ * GPT has WEEKLY-ONLY limits (no 5h window).
614
+ */
615
+ async function autoFetchOpenAIQuota(options) {
616
+ const suppressErrors = options?.suppressErrors === true;
617
+ try {
618
+ // Try direct API first (faster)
619
+ const directResult = await _openaiQuotaScraper.scrapeDirect();
620
+ if (directResult) {
621
+ writeMirrorRecord("openai", {
622
+ synced_at: directResult.scrapedAt,
623
+ source: "scrape",
624
+ weekly_used_pct: directResult.weeklyUsedPct,
625
+ weekly_resets_at: directResult.weeklyResetsAt,
626
+ weekly_resets_at_epoch: directResult.weeklyResetsAtEpoch,
627
+ // No 5h window for GPT - set to undefined
628
+ h5_used_pct: undefined,
629
+ });
630
+ return true;
631
+ }
632
+ // Fall back to browser scrape
633
+ const data = await _openaiQuotaScraper.scrape();
634
+ writeMirrorRecord("openai", {
635
+ synced_at: data.scrapedAt,
636
+ source: "scrape",
637
+ weekly_used_pct: data.weeklyUsedPct,
638
+ weekly_resets_at: data.weeklyResetsAt,
639
+ weekly_resets_at_epoch: data.weeklyResetsAtEpoch,
640
+ // No 5h window for GPT - set to undefined
641
+ h5_used_pct: undefined,
642
+ });
643
+ return true;
644
+ }
645
+ catch (error) {
646
+ if (!suppressErrors) {
647
+ console.error("[pi-harness] OpenAI quota auto-fetch skipped:", error instanceof Error ? error.message : String(error));
648
+ }
649
+ return false;
650
+ }
651
+ }
652
+ async function maybeAutoFetchQuota(modelId) {
653
+ const provider = providerFromModelId(modelId);
654
+ // MiniMax path
655
+ if (provider === "minimax") {
656
+ if (quotaAutoFetchInFlight)
657
+ return;
658
+ const nowMs = Date.now();
659
+ if (nowMs - lastQuotaAutoFetchAt < MINIMAX_REFRESH_MIN_INTERVAL_MS) {
660
+ return;
661
+ }
662
+ const mirror = mirrorStore.read();
663
+ const freshness = mirrorStore.freshness(mirror, nowMs);
664
+ const shouldFetchBaseline = !mirror || freshness === "expired";
665
+ const usageSinceSync = getMiniMaxUsageSince(mirror?.synced_at ? Date.parse(mirror.synced_at) : 0);
666
+ const shouldFetchFromUsage = usageSinceSync.tokens >= MINIMAX_REFRESH_TOKEN_THRESHOLD ||
667
+ usageSinceSync.requests >= MINIMAX_REFRESH_REQUEST_THRESHOLD ||
668
+ (freshness === "stale" && usageSinceSync.requests > 0);
669
+ if (!shouldFetchBaseline && !shouldFetchFromUsage) {
670
+ return;
671
+ }
672
+ quotaAutoFetchInFlight = true;
673
+ lastQuotaAutoFetchAt = nowMs;
674
+ try {
675
+ await autoFetchQuota({ suppressErrors: true });
676
+ }
677
+ finally {
678
+ quotaAutoFetchInFlight = false;
679
+ }
680
+ return;
681
+ }
682
+ // OpenAI path (GPT has weekly-only limits, no 5h window)
683
+ if (provider === "openai") {
684
+ if (quotaAutoFetchInFlight)
685
+ return;
686
+ const nowMs = Date.now();
687
+ if (nowMs - lastOpenAIQuotaFetchAt < OPENAI_REFRESH_MIN_INTERVAL_MS) {
688
+ return;
689
+ }
690
+ // Check if OpenAI cookie file exists
691
+ const openaiCookieFile = join(homedir(), ".config", "openai-cookies.txt");
692
+ if (!existsSync(openaiCookieFile)) {
693
+ return;
694
+ }
695
+ quotaAutoFetchInFlight = true;
696
+ lastOpenAIQuotaFetchAt = nowMs;
697
+ try {
698
+ await autoFetchOpenAIQuota({ suppressErrors: true });
699
+ }
700
+ finally {
701
+ quotaAutoFetchInFlight = false;
702
+ }
703
+ return;
704
+ }
705
+ // Other providers (GLM, Anthropic, etc.) - TUI signal path only for now
706
+ return;
707
+ }
708
+ // --- /usage — show full status ---------------------------------------
709
+ pi.registerCommand("usage", {
710
+ description: "Show Codex-style usage status (local + provider mirror)",
711
+ handler: async (_args, ctx) => {
712
+ const local = aggregateWindows(tracker.all());
713
+ const mirror = mirrorStore.read();
714
+ const output = renderStatus({
715
+ model: ctx.model?.id ?? null,
716
+ cwd: ctx.cwd ?? process.cwd(),
717
+ local,
718
+ mirror,
719
+ mirrorStore,
720
+ nowMs: Date.now(),
721
+ });
722
+ ctx.ui.notify(output, "info");
723
+ },
724
+ });
725
+ // --- /usage refresh — force auto-fetch ----------------------------
726
+ pi.registerCommand("usage-refresh", {
727
+ description: "Force refresh quota from provider console",
728
+ handler: async (_args, ctx) => {
729
+ const autoFetchAvailable = cookieQuotaAutoFetchAvailable ||
730
+ (await hasBrowserProfileAutoFetchSource());
731
+ if (!autoFetchAvailable) {
732
+ ctx.ui.notify("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`.", "warning");
733
+ return;
734
+ }
735
+ ctx.ui.notify("Fetching quota from MiniMax console...", "info");
736
+ const refreshed = await autoFetchQuota();
737
+ ctx.ui.notify(refreshed
738
+ ? "Quota refreshed. Run `/usage` to see updated status."
739
+ : "Quota refresh skipped. Check cookie validity or MiniMax auth profile, then run `/usage` again.", "info");
740
+ },
741
+ });
742
+ // --- /usage today — focused view -------------------------------------
743
+ pi.registerCommand("usage-today", {
744
+ description: "Show today's usage + 5h window",
745
+ handler: async (_args, ctx) => {
746
+ const local = aggregateWindows(tracker.all());
747
+ const lines = [
748
+ " Today's usage",
749
+ "-------------------------------------",
750
+ ` Model: ${ctx.model?.id ?? "unknown"}`,
751
+ ` Today: ${local.today.tokens} tokens · ${local.today.requests} requests · $${local.today.cost.toFixed(4)}`,
752
+ ` This 5h: ${local.five_h.tokens} tokens · ${local.five_h.requests} requests · $${local.five_h.cost.toFixed(4)}`,
753
+ "",
754
+ " Run `/usage` for full status with the latest auto-fetched provider quota.",
755
+ ];
756
+ ctx.ui.notify(lines.join("\n"), "info");
757
+ },
758
+ });
759
+ // --- /usage week — focused view --------------------------------------
760
+ pi.registerCommand("usage-week", {
761
+ description: "Show this week's usage + lifetime totals",
762
+ handler: async (_args, ctx) => {
763
+ const local = aggregateWindows(tracker.all());
764
+ const lines = [
765
+ " This week's usage",
766
+ "-------------------------------------",
767
+ ` Model: ${ctx.model?.id ?? "unknown"}`,
768
+ ` This week: ${local.weekly.tokens} tokens · ${local.weekly.requests} requests · $${local.weekly.cost.toFixed(4)}`,
769
+ ` Lifetime: ${local.lifetime.tokens} tokens · ${local.lifetime.requests} requests · $${local.lifetime.cost.toFixed(4)}`,
770
+ "",
771
+ " Run `/usage` for full status with provider mirror.",
772
+ ];
773
+ ctx.ui.notify(lines.join("\n"), "info");
774
+ },
775
+ });
776
+ // --- /usage reset — clear mirror -------------------------------------
777
+ pi.registerCommand("usage-reset", {
778
+ description: "Clear the provider mirror (force re-sync next time)",
779
+ handler: async (_args, ctx) => {
780
+ const ok = await ctx.ui.confirm("Clear provider mirror?", "This will delete ~/.pi/usage-status/mirror.json. Local usage log is preserved.");
781
+ if (!ok) {
782
+ ctx.ui.notify("Cancelled", "info");
783
+ return;
784
+ }
785
+ // Delete mirror file
786
+ try {
787
+ const { unlinkSync, existsSync } = await import("node:fs");
788
+ if (existsSync(mirrorStore["path"] ?? "")) {
789
+ // The path is private; use the JSON path getter via internal logic
790
+ // Cleaner: just unlink the known mirror path
791
+ }
792
+ // Simpler: import getMirrorPath and unlink
793
+ const { getMirrorPath } = await import("./cli.ts");
794
+ unlinkSync(getMirrorPath());
795
+ ctx.ui.notify("Mirror cleared. The next auto refresh will repopulate it.", "info");
796
+ footerStatusCtx = ctx;
797
+ refreshFooterStatus(ctx, tracker, mirrorStore, hasCookieSource, () => lastActiveProvider);
798
+ }
799
+ catch (e) {
800
+ ctx.ui.notify(`Failed to clear mirror: ${e}`, "error");
801
+ }
802
+ },
803
+ });
804
+ // --- /github-login — Connect GitHub Gist for clipboard sync ------------
805
+ registerGithubLoginCommand(pi);
806
+ // --- Ctrl+Shift+C — Copy to clipboard + sync to Gist ------------
807
+ // NOTE: Shortcut disabled to avoid conflict with pi-usage-status extension
808
+ // which also registers ctrl+shift+c for clipboard sync.
809
+ // The copyAndSync function is still exported and can be called manually.
810
+ // registerCopySyncShortcut(pi, Key);
811
+ // --- /harness start — Start a new harness job ----------------------
812
+ pi.registerCommand("harness-start", {
813
+ description: "Start a new harness job: /harness start <requirement>",
814
+ handler: async (args, ctx) => {
815
+ if (!args.trim()) {
816
+ ctx.ui.notify("Usage: /harness start <requirement>", "error");
817
+ return;
818
+ }
819
+ const jobId = `job-${Date.now()}`;
820
+ const requirement = args.trim();
821
+ ctx.ui.notify(`Starting harness job ${jobId}...`, "info");
822
+ try {
823
+ const cm = await getCheckpointManager();
824
+ const machine = new JobStateMachine({ checkpointManager: cm });
825
+ const result = await machine.createJob(jobId, requirement);
826
+ if (!result.success) {
827
+ ctx.ui.notify(`Failed to create job: ${result.error}`, "error");
828
+ return;
829
+ }
830
+ // Create task graph using heuristic planner
831
+ const planner = new MasterPlanner();
832
+ const planResult = await planner.createPlan(requirement, jobId, HARNESS_ROOT_DIR);
833
+ if (!planResult.success) {
834
+ ctx.ui.notify(`Failed to create plan: ${planResult.error}`, "error");
835
+ return;
836
+ }
837
+ // Create blackboard
838
+ const blackboard = createBlackboard(jobId, HARNESS_ROOT_DIR, planResult.graph);
839
+ // Create repair engine
840
+ const repairEngine = new RepairEngine(HARNESS_ROOT_DIR);
841
+ // Store session
842
+ currentSession = {
843
+ jobId,
844
+ machine,
845
+ graph: createTaskGraphManager(),
846
+ blackboard,
847
+ repairEngine,
848
+ createdAt: new Date().toISOString(),
849
+ };
850
+ const taskCount = planResult.graph?.nodes
851
+ ? Object.keys(planResult.graph.nodes).length
852
+ : 0;
853
+ ctx.ui.notify(`Job ${jobId} created with ${taskCount} tasks.\n` +
854
+ `Requirement: ${requirement}\n\n` +
855
+ `Run /harness status to see tasks, or /harness tasks to list them.`, "info");
856
+ }
857
+ catch (e) {
858
+ ctx.ui.notify(`Error starting harness: ${e}`, "error");
859
+ }
860
+ },
861
+ });
862
+ // --- /harness status — Show harness job status ---------------------
863
+ pi.registerCommand("harness-status", {
864
+ description: "Show current harness job status",
865
+ handler: async (_args, ctx) => {
866
+ if (!currentSession) {
867
+ ctx.ui.notify("No active harness job. Run /harness start <requirement> to begin.", "info");
868
+ return;
869
+ }
870
+ const summary = currentSession.machine.getStatusSummary();
871
+ if (!summary) {
872
+ ctx.ui.notify("Failed to get job status.", "error");
873
+ return;
874
+ }
875
+ const progress = currentSession.graph.getProgressSummary();
876
+ const lines = [
877
+ `Harness Job Status`,
878
+ `${"-".repeat(40)}`,
879
+ `Job ID: ${currentSession.jobId}`,
880
+ `Status: ${summary.status}`,
881
+ `Terminal: ${summary.isTerminal ? "Yes" : "No"}`,
882
+ `Can Resume: ${summary.canResume ? "Yes" : "No"}`,
883
+ `${"-".repeat(40)}`,
884
+ `Tasks: ${progress.done}/${progress.total} done, ${progress.running} running, ${progress.failed} failed`,
885
+ `Created: ${currentSession.createdAt}`,
886
+ `${"-".repeat(40)}`,
887
+ `Run /harness tasks for task list`,
888
+ ];
889
+ ctx.ui.notify(lines.join("\n"), "info");
890
+ },
891
+ });
892
+ // --- /harness tasks — List all tasks -------------------------------
893
+ pi.registerCommand("harness-tasks", {
894
+ description: "List all tasks in the current harness job",
895
+ handler: async (_args, ctx) => {
896
+ if (!currentSession) {
897
+ ctx.ui.notify("No active harness job. Run /harness start <requirement> to begin.", "info");
898
+ return;
899
+ }
900
+ const tasks = currentSession.graph.getAllTasks();
901
+ if (tasks.length === 0) {
902
+ ctx.ui.notify("No tasks found. The job may not have been planned yet.", "info");
903
+ return;
904
+ }
905
+ const lines = [
906
+ `Tasks for Job ${currentSession.jobId}`,
907
+ `${"-".repeat(50)}`,
908
+ ];
909
+ for (const task of tasks) {
910
+ const status = task.status.padEnd(10);
911
+ lines.push(`[${task.id}] ${status} ${task.title}`);
912
+ }
913
+ lines.push(`${"-".repeat(50)}`);
914
+ const ready = currentSession.graph.getReadyTasks();
915
+ lines.push(`${ready.length} tasks ready to execute.`);
916
+ ctx.ui.notify(lines.join("\n"), "info");
917
+ },
918
+ });
919
+ // --- /harness pause — Pause the harness job -----------------------
920
+ pi.registerCommand("harness-pause", {
921
+ description: "Pause the current harness job",
922
+ handler: async (_args, ctx) => {
923
+ if (!currentSession) {
924
+ ctx.ui.notify("No active harness job to pause.", "info");
925
+ return;
926
+ }
927
+ const checkpoint = currentSession.machine.getCheckpoint();
928
+ if (!checkpoint) {
929
+ ctx.ui.notify("Failed to get checkpoint.", "error");
930
+ return;
931
+ }
932
+ // Read 5h reset epoch from mirror so we can schedule auto-resume
933
+ const mirror = mirrorStore.readProvider("minimax");
934
+ const resumeAtIso = mirror?.h5_resets_at_epoch
935
+ ? new Date(mirror.h5_resets_at_epoch).toISOString()
936
+ : undefined;
937
+ const result = await currentSession.machine.transition("paused_quota");
938
+ if (!result.success) {
939
+ ctx.ui.notify(`Failed to pause: ${result.error}`, "error");
940
+ return;
941
+ }
942
+ // Persist resumeAt so auto-resume survives worker restart
943
+ if (resumeAtIso) {
944
+ await currentSession.machine.setResumeTime(resumeAtIso);
945
+ }
946
+ // Schedule auto-resume for 5h quota
947
+ if (resumeAtIso) {
948
+ const scheduled = scheduleAutoResume("minimax", currentSession.machine, mirrorStore);
949
+ ctx.ui.notify(`Job paused.\n` +
950
+ `Auto-resume at ${scheduled ?? resumeAtIso} (5h quota exhausted).\n` +
951
+ `Run /harness cancel to abort.`, "info");
952
+ }
953
+ else {
954
+ ctx.ui.notify(`Job ${currentSession.jobId} paused.\n` +
955
+ `Current status: paused_quota\n` +
956
+ `Run /harness resume to continue.`, "info");
957
+ }
958
+ },
959
+ });
960
+ // --- /harness resume — Resume the harness job -----------------------
961
+ pi.registerCommand("harness-resume", {
962
+ description: "Resume a paused harness job",
963
+ handler: async (_args, ctx) => {
964
+ if (!currentSession) {
965
+ ctx.ui.notify("No active harness job to resume.", "info");
966
+ return;
967
+ }
968
+ const checkpoint = currentSession.machine.getCheckpoint();
969
+ if (!checkpoint || checkpoint.status !== "paused_quota") {
970
+ ctx.ui.notify("Job is not paused. Run /harness start to begin a new job.", "info");
971
+ return;
972
+ }
973
+ const result = await currentSession.machine.transition("running");
974
+ if (!result.success) {
975
+ ctx.ui.notify(`Failed to resume: ${result.error}`, "error");
976
+ return;
977
+ }
978
+ // Cancel any scheduled auto-resume for this job
979
+ cancelAutoResume(currentSession.jobId);
980
+ ctx.ui.notify(`Job ${currentSession.jobId} resumed.\n` +
981
+ `Current status: running\n` +
982
+ `Run /harness status to monitor progress.`, "info");
983
+ },
984
+ });
985
+ // --- /harness cancel — Cancel the harness job ----------------------
986
+ pi.registerCommand("harness-cancel", {
987
+ description: "Cancel the current harness job",
988
+ handler: async (_args, ctx) => {
989
+ if (!currentSession) {
990
+ ctx.ui.notify("No active harness job to cancel.", "info");
991
+ return;
992
+ }
993
+ const ok = await ctx.ui.confirm(`Cancel job ${currentSession.jobId}?`, "This will mark the job as cancelled. Task state is preserved but work stops.");
994
+ if (!ok) {
995
+ ctx.ui.notify("Cancelled", "info");
996
+ return;
997
+ }
998
+ const result = await currentSession.machine.transition("cancelled");
999
+ if (!result.success) {
1000
+ ctx.ui.notify(`Failed to cancel: ${result.error}`, "error");
1001
+ return;
1002
+ }
1003
+ cancelAutoResume(currentSession.jobId);
1004
+ const sessionJobId = currentSession.jobId;
1005
+ currentSession = null;
1006
+ ctx.ui.notify(`Job ${sessionJobId} cancelled.\n` +
1007
+ `Run \`/harness start\` to begin a new job.`, "info");
1008
+ },
1009
+ });
1010
+ // --- Footer status (persistent badge) --------------------------------
1011
+ pi.on("session_start", (_event, ctx) => {
1012
+ footerStatusCtx = ctx;
1013
+ refreshFooterStatus(ctx, tracker, mirrorStore, hasCookieSource, () => lastActiveProvider);
1014
+ });
1015
+ pi.on("turn_end", (_event, ctx) => {
1016
+ noteActiveProvider(ctx.model?.id ?? null);
1017
+ footerStatusCtx = ctx;
1018
+ refreshFooterStatus(ctx, tracker, mirrorStore, hasCookieSource, () => lastActiveProvider);
1019
+ void maybeAutoFetchQuota(ctx.model?.id ?? null);
1020
+ maybeTriggerProactiveCompact(ctx);
1021
+ maybeNotifyContextUsage(ctx);
1022
+ });
1023
+ pi.on("agent_end", () => {
1024
+ setTimeout(() => {
1025
+ if (!pendingOutputLimitResumeAfterSettled) {
1026
+ return;
1027
+ }
1028
+ pendingOutputLimitResumeAfterSettled = false;
1029
+ queueAutoResume("output-limit-settled", OUTPUT_LIMIT_RESUME_PROMPT, "followUp");
1030
+ }, 0);
1031
+ });
1032
+ pi.on("session_compact", (event, ctx) => {
1033
+ footerStatusCtx = ctx;
1034
+ proactiveCompactInFlight = false;
1035
+ lastProactiveCompactAt = Date.now();
1036
+ consecutiveCompactFailures = 0;
1037
+ proactiveCompactCircuitReported = false;
1038
+ refreshFooterStatus(ctx, tracker, mirrorStore, hasCookieSource, () => lastActiveProvider);
1039
+ const forceOutputLimitResume = pendingOutputLimitResumeAfterCompact;
1040
+ pendingOutputLimitResumeAfterCompact = false;
1041
+ if (!shouldQueuePostCompactionResume(event, ctx.hasPendingMessages(), {
1042
+ force: forceOutputLimitResume,
1043
+ })) {
1044
+ return;
1045
+ }
1046
+ if (forceOutputLimitResume) {
1047
+ queueAutoResume("post-compact-output-limit", OUTPUT_LIMIT_RESUME_PROMPT, event.willRetry ? "steer" : "followUp");
1048
+ return;
1049
+ }
1050
+ outputLimitResumeAttempts = 0;
1051
+ // pi.dev expects the literal "resume" command to continue after compaction.
1052
+ queueAutoResume("post-compact", "resume", "followUp");
1053
+ });
1054
+ // --- Periodic quota refresh every 15 minutes ---
1055
+ // `maybeAutoFetchQuota` checks its own rate-limit (MINIMAX_REFRESH_MIN_INTERVAL_MS)
1056
+ // so this is safe to call frequently.
1057
+ setInterval(() => {
1058
+ void maybeAutoFetchQuota(lastActiveProvider ?? null);
1059
+ }, MINIMAX_REFRESH_MIN_INTERVAL_MS);
1060
+ function queueAutoResume(reason, content, deliverAs) {
1061
+ try {
1062
+ pi.sendUserMessage(content, { deliverAs });
1063
+ }
1064
+ catch (error) {
1065
+ console.error(`[pi-harness] Failed to queue ${reason} auto-resume:`, error instanceof Error ? error.message : String(error));
1066
+ }
1067
+ }
1068
+ function maybeTriggerProactiveCompact(ctx) {
1069
+ if (proactiveCompactInFlight) {
1070
+ return;
1071
+ }
1072
+ if (consecutiveCompactFailures >= MAX_PROACTIVE_COMPACT_FAILURES) {
1073
+ if (!proactiveCompactCircuitReported) {
1074
+ proactiveCompactCircuitReported = true;
1075
+ console.error(`[pi-harness] Proactive compact disabled after ${consecutiveCompactFailures} consecutive failures`);
1076
+ }
1077
+ return;
1078
+ }
1079
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
1080
+ return;
1081
+ }
1082
+ const usage = ctx.getContextUsage();
1083
+ if (!shouldTriggerProactiveCompact(usage)) {
1084
+ return;
1085
+ }
1086
+ if (Date.now() - lastProactiveCompactAt < PROACTIVE_COMPACT_COOLDOWN_MS) {
1087
+ return;
1088
+ }
1089
+ proactiveCompactInFlight = true;
1090
+ lastProactiveCompactAt = Date.now();
1091
+ const compactOptions = {
1092
+ customInstructions: "Preserve the current task, recent code changes, pending work, exact next step, and any unresolved errors. This compaction was triggered proactively near the context limit. After compaction, continue seamlessly without asking the user to resume or recap.",
1093
+ onComplete: () => {
1094
+ proactiveCompactInFlight = false;
1095
+ lastProactiveCompactAt = Date.now();
1096
+ consecutiveCompactFailures = 0;
1097
+ proactiveCompactCircuitReported = false;
1098
+ },
1099
+ onError: (error) => {
1100
+ proactiveCompactInFlight = false;
1101
+ consecutiveCompactFailures += 1;
1102
+ console.error("[pi-harness] Proactive compact failed:", error.message);
1103
+ },
1104
+ };
1105
+ ctx.compact(compactOptions);
1106
+ }
1107
+ }
1108
+ // ----------------------------------------------------------------------
1109
+ // Helper: refresh persistent footer status with one-line summary
1110
+ // ----------------------------------------------------------------------
1111
+ function refreshFooterStatus(ctx, tracker, mirrorStore, hasCookieSource, getActiveProvider = () => null) {
1112
+ const nowMs = Date.now();
1113
+ const local = aggregateWindows(tracker.all());
1114
+ const provider = getActiveProvider();
1115
+ const mirror = provider ? mirrorStore.readProvider(provider) : null;
1116
+ // "[DEBUG refreshFooterStatus] mirror =",
1117
+ // mirror ? JSON.stringify(mirror) : null,
1118
+ // );
1119
+ const freshness = mirrorStore.freshness(mirror, nowMs);
1120
+ ctx.ui.setStatus("harness-runtime", buildFooterStatusValue(local, mirror, freshness, hasCookieSource(), provider));
1121
+ }