pi-soly 2.3.3 → 2.4.1

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.ts CHANGED
@@ -65,6 +65,9 @@ import { registerTools } from "./tools.ts";
65
65
  import { registerWorkflows } from "./workflows/index.ts";
66
66
  import { readGitContext, buildGitSection, type GitContext } from "./git.ts";
67
67
  import { startHotReload, type HotReloadHandle } from "./hotreload.ts";
68
+ import { registerQuotaProvider } from "./quota/registry.ts";
69
+ import { minimaxProvider } from "./quota/minimax.ts";
70
+ import { startQuotaPoller, type QuotaPoller } from "./quota/poller.ts";
68
71
  import { detectEnv, buildEnvSection, type EnvSummary } from "./env.ts";
69
72
  import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.ts";
70
73
  import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.ts";
@@ -101,6 +104,12 @@ When terminal text isn't the best medium, reach for these (details + when-NOT in
101
104
  - \`html_artifact\` — render HTML to a self-contained, browseable per-project gallery.`;
102
105
 
103
106
  export default function solyExtension(pi: ExtensionAPI) {
107
+ // ============================================================================
108
+ // Register built-in quota providers (MiniMax via mmx CLI).
109
+ // Adding a new provider = new adapter + registerQuotaProvider() here.
110
+ // ============================================================================
111
+ registerQuotaProvider(minimaxProvider);
112
+
104
113
  // ============================================================================
105
114
  // State (module-local, lives for the duration of one extension instance)
106
115
  // ============================================================================
@@ -168,6 +177,7 @@ export default function solyExtension(pi: ExtensionAPI) {
168
177
 
169
178
  // Hot reload watcher for rules
170
179
  let hotReload: HotReloadHandle | null = null;
180
+ let quotaPoller: QuotaPoller | null = null;
171
181
 
172
182
  // Session stats (computed on demand)
173
183
  let sessionStats: { turns: number; tokensEstimate: number } = { turns: 0, tokensEstimate: 0 };
@@ -536,6 +546,12 @@ export default function solyExtension(pi: ExtensionAPI) {
536
546
  });
537
547
  },
538
548
  });
549
+
550
+ // Start the background quota poller (reads modelProvider from
551
+ // ChromeData each tick, resolves the registered adapter, writes
552
+ // quotaPercent/quotaResetsLabel back for the footer to render).
553
+ if (quotaPoller) quotaPoller.stop();
554
+ quotaPoller = startQuotaPoller(chrome.data, () => getActiveConfig().chrome.enabled);
539
555
  // Editors save in bursts (write to .tmp, rename, touch). Coalesce
540
556
  // those rapid reload events into a single sub-line event under the
541
557
  // Working indicator (└─ reloaded 47 rules). Errors here are real
@@ -633,6 +649,11 @@ export default function solyExtension(pi: ExtensionAPI) {
633
649
  hotReload.stop();
634
650
  hotReload = null;
635
651
  }
652
+ // Stop the background quota poller
653
+ if (quotaPoller) {
654
+ quotaPoller.stop();
655
+ quotaPoller = null;
656
+ }
636
657
  // Restore pi's native footer/widgets/indicator before teardown
637
658
  chrome.dispose(ctx.ui);
638
659
  // Persist rule mtimes so the next session can show the diff
@@ -952,13 +973,13 @@ export default function solyExtension(pi: ExtensionAPI) {
952
973
  try {
953
974
  m.default(pi);
954
975
  } catch (err) {
955
- console.error("[soly] MCP adapter failed to initialize:", err);
976
+ emit("[soly] MCP adapter failed to initialize:" + ": " + (err instanceof Error ? err.message : String(err)), "error");
956
977
  }
957
978
  })
958
979
  .catch((err) => {
959
- console.error("[soly] MCP adapter unavailable (load failed):", err);
980
+ emit("[soly] MCP adapter unavailable (load failed):" + ": " + (err instanceof Error ? err.message : String(err)), "error");
960
981
  });
961
982
  } catch (err) {
962
- console.error("[soly] MCP adapter unavailable (load threw):", err);
983
+ emit("[soly] MCP adapter unavailable (load threw):" + ": " + (err instanceof Error ? err.message : String(err)), "error");
963
984
  }
964
985
  }
package/mcp/config.ts CHANGED
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { getAgentPath } from "./agent-dir.ts";
6
6
  import type { McpConfig, ServerEntry, McpSettings, ImportKind, ServerProvenance } from "./types.ts";
7
+ import { emit } from "../visual/event-sink.ts";
7
8
 
8
9
  const GENERIC_GLOBAL_CONFIG_PATH = join(homedir(), ".config", "mcp", "mcp.json");
9
10
  const PROJECT_CONFIG_NAME = ".mcp.json";
@@ -279,7 +280,7 @@ function expandImports(config: McpConfig, cwd = process.cwd()): McpConfig {
279
280
  }
280
281
  }
281
282
  } catch (error) {
282
- console.warn(`Failed to import MCP config from ${importKind}:`, error);
283
+ emit(`Failed to import MCP config from ${importKind}: ${error instanceof Error ? error.message : String(error)}`, "warning");
283
284
  }
284
285
  }
285
286
 
@@ -316,7 +317,7 @@ function readValidatedConfig(path: string, label: string): McpConfig | null {
316
317
  try {
317
318
  return validateConfig(JSON.parse(readFileSync(path, "utf-8")));
318
319
  } catch (error) {
319
- console.warn(`Failed to load ${label}:`, error);
320
+ emit(`Failed to load ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
320
321
  return null;
321
322
  }
322
323
  }
@@ -13,6 +13,7 @@ import { resourceNameToToolName } from "./resource-tools.ts";
13
13
  import { authenticate, supportsOAuth } from "./mcp-auth-flow.ts";
14
14
  import { formatAuthRequiredMessage } from "./utils.ts";
15
15
  import { ToolCache, cacheKey } from "./tool-cache.ts";
16
+ import { emit } from "../visual/event-sink.ts";
16
17
 
17
18
  const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
18
19
 
@@ -133,11 +134,11 @@ export function resolveDirectTools(
133
134
  if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
134
135
  const prefixedName = formatToolName(tool.name, serverName, prefix);
135
136
  if (BUILTIN_NAMES.has(prefixedName)) {
136
- console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`);
137
+ emit(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`, "warning");
137
138
  continue;
138
139
  }
139
140
  if (seenNames.has(prefixedName)) {
140
- console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`);
141
+ emit(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`, "warning");
141
142
  continue;
142
143
  }
143
144
  seenNames.add(prefixedName);
@@ -159,11 +160,11 @@ export function resolveDirectTools(
159
160
  if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
160
161
  const prefixedName = formatToolName(baseName, serverName, prefix);
161
162
  if (BUILTIN_NAMES.has(prefixedName)) {
162
- console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`);
163
+ emit(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`, "warning");
163
164
  continue;
164
165
  }
165
166
  if (seenNames.has(prefixedName)) {
166
- console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`);
167
+ emit(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`, "warning");
167
168
  continue;
168
169
  }
169
170
  seenNames.add(prefixedName);
@@ -17,6 +17,8 @@
17
17
  // resolve ext-apps' types (which themselves reference the missing sdk subpath).
18
18
  // =============================================================================
19
19
 
20
+ import { emit } from "../visual/event-sink.ts";
21
+
20
22
  /** Minimal shape of the bits of ext-apps/app-bridge we use. */
21
23
  interface AppBridge {
22
24
  RESOURCE_MIME_TYPE: string;
@@ -43,9 +45,10 @@ export async function preloadAppBridge(): Promise<void> {
43
45
  cached = (await import(APP_BRIDGE_SPECIFIER)) as unknown as AppBridge;
44
46
  } catch {
45
47
  cached = null;
46
- console.error(
47
- "[soly] MCP UI (ext-apps/app-bridge) could not load — app-bridge features are disabled " +
48
- "(upstream ext-apps/sdk version mismatch, not a soly bug). Core MCP is unaffected.",
48
+ emit(
49
+ "MCP UI (ext-apps/app-bridge) could not load — app-bridge features disabled " +
50
+ "(upstream ext-apps/sdk version mismatch, not a soly bug). Core MCP unaffected.",
51
+ "warning",
49
52
  );
50
53
  }
51
54
  }
@@ -1,4 +1,5 @@
1
1
  import type { UiHostContext, UiResourceContent, UiResourceCsp } from "./types.ts";
2
+ import { emit } from "../visual/event-sink.ts";
2
3
 
3
4
  // Use locally bundled AppBridge to avoid CDN Zod bundling issues
4
5
  const DEFAULT_APP_BRIDGE_MODULE_URL = "/app-bridge.bundle.js";
@@ -268,7 +269,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
268
269
  const transport = new PostMessageTransport(iframe.contentWindow, null);
269
270
  await bridge.connect(transport);
270
271
  } catch (error) {
271
- console.error("[host] Bridge connection failed:", error);
272
+ emit("[host] Bridge connection failed:" + ": " + (error instanceof Error ? error.message : String(error)), "error");
272
273
  showError("Failed to initialize AppBridge: " + String(error));
273
274
  }
274
275
 
package/mcp/index.ts CHANGED
@@ -58,7 +58,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
58
58
  await currentState.lifecycle.gracefulShutdown();
59
59
  } catch (error) {
60
60
  if (flushError) {
61
- console.error("MCP: graceful shutdown failed after metadata flush error", error);
61
+ emit("MCP: graceful shutdown failed after metadata flush error" + ": " + (error instanceof Error ? error.message : String(error)), "error");
62
62
  } else {
63
63
  throw error;
64
64
  }
@@ -122,7 +122,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
122
122
  shutdownOAuth(),
123
123
  ]);
124
124
  } catch (error) {
125
- console.error("MCP: failed to shut down previous session state", error);
125
+ emit("MCP: failed to shut down previous session state" + ": " + (error instanceof Error ? error.message : String(error)), "error");
126
126
  }
127
127
 
128
128
  if (generation !== lifecycleGeneration) {
@@ -130,7 +130,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
130
130
  }
131
131
 
132
132
  await initializeOAuth().catch(err => {
133
- console.error("MCP OAuth initialization failed:", err);
133
+ emit("MCP OAuth initialization failed:" + ": " + (err instanceof Error ? err.message : String(err)), "error");
134
134
  });
135
135
 
136
136
  // Load the (optional, sometimes-broken upstream) ext-apps UI bridge once,
@@ -145,7 +145,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
145
145
  try {
146
146
  await shutdownState(nextState, "stale_session_start");
147
147
  } catch (error) {
148
- console.error("MCP: failed to clean stale session state", error);
148
+ emit("MCP: failed to clean stale session state" + ": " + (error instanceof Error ? error.message : String(error)), "error");
149
149
  }
150
150
  return;
151
151
  }
@@ -161,7 +161,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
161
161
  if (initPromise !== promise && initPromise !== null) {
162
162
  return;
163
163
  }
164
- console.error("MCP initialization failed:", err);
164
+ emit("MCP initialization failed:" + ": " + (err instanceof Error ? err.message : String(err)), "error");
165
165
  initPromise = null;
166
166
  });
167
167
  });
@@ -179,7 +179,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
179
179
  shutdownOAuth(),
180
180
  ]);
181
181
  } catch (error) {
182
- console.error("MCP: session shutdown cleanup failed", error);
182
+ emit("MCP: session shutdown cleanup failed" + ": " + (error instanceof Error ? error.message : String(error)), "error");
183
183
  }
184
184
  });
185
185
 
package/mcp/init.ts CHANGED
@@ -161,7 +161,7 @@ export async function initializeMcp(
161
161
  if (ctx.hasUI) {
162
162
  emit(`MCP: Failed to connect to ${name}: ${error}`, "error");
163
163
  }
164
- console.error(`MCP: Failed to connect to ${name}: ${error}`);
164
+ emit(`MCP: Failed to connect to ${name}: ${error}`, "error");
165
165
  continue;
166
166
  }
167
167
 
package/mcp/logger.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  * Provides structured, contextual logs with levels.
4
4
  */
5
5
 
6
+ import { emit as emitEvent } from "../visual/event-sink.ts";
7
+
6
8
  export type LogLevel = "debug" | "info" | "warn" | "error";
7
9
 
8
10
  export interface LogContext {
@@ -73,19 +75,22 @@ class Logger {
73
75
  timestamp: new Date(),
74
76
  };
75
77
 
76
- // Default console output
77
- const prefix = LEVEL_PREFIX[level];
78
+ // Route through the event sink → Working sub-line (no console output).
79
+ // Context is appended for usefulness; the console-style [MCP-UI:*]
80
+ // prefix is dropped (the sub-line glyph carries the level).
78
81
  const contextStr = formatContext(entry.context);
79
- const fullMessage = contextStr ? `${prefix} ${message} ${contextStr}` : `${prefix} ${message}`;
82
+ const errStr = error ? `: ${error.message}` : "";
83
+ const text = contextStr ? `${message}${errStr} ${contextStr}` : `${message}${errStr}`;
80
84
 
81
85
  if (level === "error") {
82
- console.error(fullMessage, error ?? "");
86
+ emitEvent(text, "error");
83
87
  } else if (level === "warn") {
84
- console.warn(fullMessage);
88
+ emitEvent(text, "warning");
85
89
  } else if (level === "debug") {
86
- console.debug(fullMessage);
90
+ // Debug is too verbose for a single-line toolbar slot; drop unless
91
+ // a custom handler (e.g. file logger) is registered.
87
92
  } else {
88
- console.log(fullMessage);
93
+ emitEvent(text, "info");
89
94
  }
90
95
 
91
96
  // Custom handlers
@@ -32,6 +32,7 @@ import {
32
32
  type StoredTokens,
33
33
  } from "./mcp-auth.ts"
34
34
  import type { ServerEntry } from "./types.ts"
35
+ import { emit } from "../visual/event-sink.ts";
35
36
 
36
37
  /** Auth status for a server */
37
38
  export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
@@ -399,11 +400,11 @@ export async function authenticate(
399
400
  try {
400
401
  // Open browser. Always print the URL first so remote/headless users can copy it
401
402
  // even when the OS browser handoff is unavailable or invisible.
402
- console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
403
+ emit(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`, "info");
403
404
  try {
404
405
  await open(authorizationUrl)
405
406
  } catch (error) {
406
- console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
407
+ emit(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, "warning");
407
408
  }
408
409
 
409
410
  // Wait for callback
@@ -462,7 +463,7 @@ export async function getValidToken(
462
463
 
463
464
  if (expired === true && entry.tokens.refreshToken) {
464
465
  // Token is expired, try to refresh
465
- console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
466
+ emit(`MCP Auth: Token expired for ${serverName}, attempting refresh`, "info");
466
467
 
467
468
  try {
468
469
  // Create auth provider for token refresh
@@ -472,7 +473,7 @@ export async function getValidToken(
472
473
 
473
474
  const clientInfo = await authProvider.clientInformation()
474
475
  if (!clientInfo) {
475
- console.log(`MCP Auth: No client info for refresh for ${serverName}`)
476
+ emit(`MCP Auth: No client info for refresh for ${serverName}`, "info");
476
477
  return null
477
478
  }
478
479
 
@@ -483,7 +484,7 @@ export async function getValidToken(
483
484
  const refreshed = await getAuthForUrl(serverName, serverUrl)
484
485
  return refreshed?.tokens ?? null
485
486
  } catch (error) {
486
- console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
487
+ emit(`MCP Auth: Token refresh failed for ${serverName}`, "error");
487
488
  return null
488
489
  }
489
490
  }
@@ -519,7 +520,7 @@ export async function removeAuth(serverName: string): Promise<void> {
519
520
  await clearPendingAuth(serverName, oauthState)
520
521
  clearAllCredentials(serverName)
521
522
  await clearOAuthState(serverName)
522
- console.log(`MCP Auth: Removed credentials for ${serverName}`)
523
+ emit(`MCP Auth: Removed credentials for ${serverName}`, "info");
523
524
  }
524
525
 
525
526
  /**
package/mcp/mcp-auth.ts CHANGED
@@ -12,6 +12,7 @@ import { createHash } from 'crypto';
12
12
  import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
13
13
  import { join } from 'path';
14
14
  import { getAgentPath } from './agent-dir.ts';
15
+ import { emit } from "../visual/event-sink.ts";
15
16
 
16
17
  /** OAuth token storage format */
17
18
  export interface StoredTokens {
@@ -86,7 +87,7 @@ function readAuthEntry(serverName: string): AuthEntry | undefined {
86
87
  const data = readFileSync(filePath, 'utf-8');
87
88
  return JSON.parse(data) as AuthEntry;
88
89
  } catch (error) {
89
- console.error(`Failed to read auth entry for ${serverName}:`, error);
90
+ emit(`Failed to read auth entry for ${serverName}:` + ": " + (error instanceof Error ? error.message : String(error)), "error");
90
91
  return undefined;
91
92
  }
92
93
  }
@@ -155,7 +156,7 @@ export function removeAuthEntry(serverName: string): void {
155
156
  }
156
157
  }
157
158
  } catch (error) {
158
- console.error(`Failed to remove auth entry for ${serverName}:`, error);
159
+ emit(`Failed to remove auth entry for ${serverName}:` + ": " + (error instanceof Error ? error.message : String(error)), "error");
159
160
  }
160
161
  }
161
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.3.3",
3
+ "version": "2.4.1",
4
4
  "description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -63,6 +63,7 @@
63
63
  "state.ts",
64
64
  "hotreload.ts",
65
65
  "built-in-rules",
66
+ "quota",
66
67
  ".assets"
67
68
  ],
68
69
  "keywords": [
@@ -0,0 +1,23 @@
1
+ // =============================================================================
2
+ // quota/format.ts — human-readable reset time
3
+ // =============================================================================
4
+ //
5
+ // Turns a millisecond duration into a compact English label for the toolbar:
6
+ // 1_200_000 → "in 20m"
7
+ // 9_000_000 → "in 2h30m"
8
+ // 3_600_000 → "in 1h"
9
+ // 0 → "now"
10
+ //
11
+ // Kept pure (no DOM, no Date) so it's trivially testable.
12
+ // =============================================================================
13
+
14
+ /** Format a millisecond duration as a compact "in Nm" / "in Nh" / "in NhMm". */
15
+ export function formatReset(ms: number): string {
16
+ const totalMin = Math.round(ms / 60_000);
17
+ if (totalMin <= 0) return "now";
18
+ if (totalMin < 60) return `in ${totalMin}m`;
19
+ const hours = Math.floor(totalMin / 60);
20
+ const mins = totalMin % 60;
21
+ if (mins === 0) return `in ${hours}h`;
22
+ return `in ${hours}h${mins}m`;
23
+ }
@@ -0,0 +1,81 @@
1
+ // =============================================================================
2
+ // quota/minimax.ts — MiniMax quota provider (via mmx CLI)
3
+ // =============================================================================
4
+ //
5
+ // Shells out to `mmx quota show --output json --quiet` every poll cycle.
6
+ // Uses mmx (mmx-cli) because it already handles auth (~/.mmx/config.json),
7
+ // region detection (global vs cn), and the billing endpoint. Reimplementing
8
+ // that as direct HTTP would duplicate mmx's logic for no gain.
9
+ //
10
+ // The response shape (confirmed against a live MiniMax-M3 key):
11
+ // {
12
+ // "model_remains": [
13
+ // {
14
+ // "model_name": "general", ← chat models (MiniMax-M3)
15
+ // "current_interval_remaining_percent": 53,
16
+ // "remains_time": 1639649, ← ms until interval resets
17
+ // ...
18
+ // },
19
+ // { "model_name": "video", ... }
20
+ // ],
21
+ // "base_resp": { "status_code": 0, ... }
22
+ // }
23
+ //
24
+ // We pick the "general" entry — that's the quota bucket MiniMax-M3 (and all
25
+ // text/chat models) draw from. Video/music have separate buckets.
26
+ // =============================================================================
27
+
28
+ import { execFile } from "node:child_process";
29
+ import type { QuotaProvider, QuotaSnapshot } from "./types.ts";
30
+
31
+ /** Minimal shape we read from `mmx quota show --output json`. */
32
+ type MinimaxQuotaResponse = {
33
+ model_remains?: Array<{
34
+ model_name?: string;
35
+ current_interval_remaining_percent?: number;
36
+ remains_time?: number;
37
+ }>;
38
+ base_resp?: { status_code?: number };
39
+ };
40
+
41
+ /** Run mmx and capture stdout. Resolves to null on any failure
42
+ * (mmx missing, non-zero exit, timeout, bad JSON). Never throws. */
43
+ function runMmx(args: string[], timeoutMs: number): Promise<string | null> {
44
+ return new Promise((resolve) => {
45
+ execFile("mmx", args, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, stdout) => {
46
+ if (err) resolve(null);
47
+ else resolve(stdout ?? "");
48
+ });
49
+ });
50
+ }
51
+
52
+ /** Parse the mmx quota response, returning the "general" model snapshot. */
53
+ function parseGeneralQuota(raw: string): QuotaSnapshot | null {
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ } catch {
58
+ return null; // not valid JSON — mmx printed an error to stdout
59
+ }
60
+ // Cast at the boundary (single documented place, per code-style rules).
61
+ const data = parsed as MinimaxQuotaResponse;
62
+ if (data.base_resp?.status_code !== 0) return null;
63
+ const general = data.model_remains?.find((m) => m.model_name === "general");
64
+ if (!general) return null;
65
+ const pct = general.current_interval_remaining_percent;
66
+ if (typeof pct !== "number") return null;
67
+ return {
68
+ remainingPercent: pct,
69
+ resetsInMs: typeof general.remains_time === "number" ? general.remains_time : null,
70
+ };
71
+ }
72
+
73
+ /** MiniMax quota provider. Polls `mmx quota show`. */
74
+ export const minimaxProvider: QuotaProvider = {
75
+ id: "minimax",
76
+ async fetch(): Promise<QuotaSnapshot | null> {
77
+ const stdout = await runMmx(["quota", "show", "--output", "json", "--quiet"], 15_000);
78
+ if (stdout === null) return null;
79
+ return parseGeneralQuota(stdout);
80
+ },
81
+ };
@@ -0,0 +1,99 @@
1
+ // =============================================================================
2
+ // quota/poller.ts — background quota polling loop
3
+ // =============================================================================
4
+ //
5
+ // startQuotaPoller() reads the active model's provider id from ChromeData,
6
+ // resolves it to a registered QuotaProvider, and calls fetch() on a timer.
7
+ // The result is written back into ChromeData (quotaPercent / quotaResetsLabel)
8
+ // which the footer reads live at render time.
9
+ //
10
+ // Design notes:
11
+ // - setTimeout recursion (not setInterval) so a slow fetch can't pile up
12
+ // overlapping ticks. The next tick is scheduled only after the current
13
+ // fetch resolves.
14
+ // - On fetch failure (null), the previous snapshot is kept — stale data is
15
+ // more useful than a flashing segment. The percent naturally drifts as
16
+ // the user consumes quota between successful polls.
17
+ // - When the active provider has no registered adapter, the quota fields
18
+ // are cleared (no segment shown) — self-disabling for providers we don't
19
+ // support yet.
20
+ // - The poller is started in session_start and stopped in session_shutdown.
21
+ // It only runs while a session is active.
22
+ // =============================================================================
23
+
24
+ import type { ChromeData } from "../visual/data.ts";
25
+ import { resolveQuotaProvider } from "./registry.ts";
26
+ import { formatReset } from "./format.ts";
27
+
28
+ /** Default poll interval: 60 seconds. */
29
+ const POLL_INTERVAL_MS = 60_000;
30
+
31
+ /** A running poller handle. Call stop() to cancel the timer. */
32
+ export type QuotaPoller = {
33
+ /** Cancel the timer. Idempotent. */
34
+ stop(): void;
35
+ };
36
+
37
+ /** Start the quota poller. Reads `data.modelProvider` each tick to pick the
38
+ * right adapter. Returns a handle to stop the loop on session_shutdown.
39
+ *
40
+ * @param data - the shared ChromeData (mutated in place with quota fields)
41
+ * @param isEnabled - gate; return false to skip polling (e.g. chrome disabled)
42
+ * @param intervalMs - override poll interval (default 60_000); for tests */
43
+ export function startQuotaPoller(
44
+ data: ChromeData,
45
+ isEnabled: () => boolean,
46
+ intervalMs: number = POLL_INTERVAL_MS,
47
+ ): QuotaPoller {
48
+ let stopped = false;
49
+ let timer: ReturnType<typeof setTimeout> | null = null;
50
+
51
+ const scheduleNext = (): void => {
52
+ if (stopped) return;
53
+ timer = setTimeout(tick, intervalMs);
54
+ };
55
+
56
+ const tick = async (): Promise<void> => {
57
+ if (stopped) return;
58
+ if (!isEnabled()) {
59
+ scheduleNext();
60
+ return;
61
+ }
62
+
63
+ const providerId = data.modelProvider;
64
+ if (!providerId) {
65
+ scheduleNext();
66
+ return;
67
+ }
68
+
69
+ const provider = resolveQuotaProvider(providerId);
70
+ if (!provider) {
71
+ // No adapter for this provider — clear and skip.
72
+ data.quotaPercent = null;
73
+ data.quotaResetsLabel = null;
74
+ scheduleNext();
75
+ return;
76
+ }
77
+
78
+ const snapshot = await provider.fetch();
79
+ if (snapshot) {
80
+ data.quotaPercent = snapshot.remainingPercent;
81
+ data.quotaResetsLabel = snapshot.resetsInMs !== null ? formatReset(snapshot.resetsInMs) : null;
82
+ }
83
+ // On null (fetch failed), keep the previous snapshot — don't clear.
84
+ scheduleNext();
85
+ };
86
+
87
+ // Fire immediately so the segment appears without waiting 60s.
88
+ tick();
89
+
90
+ return {
91
+ stop(): void {
92
+ stopped = true;
93
+ if (timer) {
94
+ clearTimeout(timer);
95
+ timer = null;
96
+ }
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,39 @@
1
+ // =============================================================================
2
+ // quota/registry.ts — provider registry
3
+ // =============================================================================
4
+ //
5
+ // A simple Map<id, QuotaProvider>. Providers register themselves at module
6
+ // load (index.ts calls registerQuotaProvider for each built-in adapter).
7
+ // The poller calls resolveQuotaProvider(providerId) which tries an exact
8
+ // match first, then falls back to the base id before the first "-" (so
9
+ // "minimax-cn" resolves to a "minimax" adapter).
10
+ //
11
+ // External extensions could also register providers by importing this
12
+ // module — but pi-soly has no runtime deps, so in practice the built-in
13
+ // adapters (minimax) are all that ship today.
14
+ // =============================================================================
15
+
16
+ import type { QuotaProvider } from "./types.ts";
17
+
18
+ const providers = new Map<string, QuotaProvider>();
19
+
20
+ /** Register a quota provider. Idempotent — re-registering overwrites. */
21
+ export function registerQuotaProvider(provider: QuotaProvider): void {
22
+ providers.set(provider.id, provider);
23
+ }
24
+
25
+ /** Exact-match lookup. */
26
+ export function getQuotaProvider(id: string): QuotaProvider | undefined {
27
+ return providers.get(id);
28
+ }
29
+
30
+ /** Resolve a provider id to its adapter. Tries exact match first, then
31
+ * the base id before the first "-" (region-suffix fallback:
32
+ * "minimax-cn" → "minimax"). Returns undefined if nothing matches. */
33
+ export function resolveQuotaProvider(id: string): QuotaProvider | undefined {
34
+ const exact = providers.get(id);
35
+ if (exact) return exact;
36
+ const base = id.split("-")[0];
37
+ if (base !== id) return providers.get(base);
38
+ return undefined;
39
+ }
package/quota/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ // =============================================================================
2
+ // quota/types.ts — provider-agnostic quota snapshot
3
+ // =============================================================================
4
+ //
5
+ // A QuotaProvider knows how to fetch "how much quota is left" for one model
6
+ // provider (MiniMax, OpenAI, …). The poller (poller.ts) reads the active
7
+ // model's provider id from ChromeData, looks up the registered adapter, and
8
+ // polls it on a timer. Adding a new provider = implement this interface +
9
+ // registerQuotaProvider() — no changes to the poller or footer.
10
+ //
11
+ // This is intentionally minimal: just the two numbers the toolbar needs.
12
+ // Per-model breakdowns, weekly vs interval, cost in cents — none of that
13
+ // belongs in a status-bar segment. If a provider can't report a field, it
14
+ // returns null and the segment omits it.
15
+ // =============================================================================
16
+
17
+ /** What a provider reports about remaining quota. */
18
+ export type QuotaSnapshot = {
19
+ /** Remaining quota as a percent (0–100). Drives the main number. */
20
+ readonly remainingPercent: number;
21
+ /** Milliseconds until the current quota window resets, or null if the
22
+ * provider doesn't expose a reset time. Rendered as "in 20m". */
23
+ readonly resetsInMs: number | null;
24
+ };
25
+
26
+ /** Adapter that fetches quota for one provider.
27
+ *
28
+ * Implementations MUST NOT throw — return null on any failure (no
29
+ * credentials, provider down, CLI missing, parse error). The poller
30
+ * treats null as "no data" and leaves the previous snapshot in place. */
31
+ export interface QuotaProvider {
32
+ /** Provider id matching `ctx.model.provider` (e.g. "minimax").
33
+ * Region-suffixed ids ("minimax-cn") fall back to the base id. */
34
+ readonly id: string;
35
+ /** Fetch the current snapshot, or null if unavailable. */
36
+ fetch(): Promise<QuotaSnapshot | null>;
37
+ }
package/quota/zai.ts ADDED
@@ -0,0 +1,45 @@
1
+ // =============================================================================
2
+ // quota/zai.ts — Zhipu AI (zai / GLM) quota provider
3
+ // =============================================================================
4
+ //
5
+ // Adapter for the "zai" provider id. Implement fetch() to return a
6
+ // QuotaSnapshot — the poller + footer do the rest.
7
+ //
8
+ // TODO(zai): fill in the real fetch. Options:
9
+ // (a) shell out to a CLI (like minimax does with `mmx`) if one exists
10
+ // (b) read the API key from env (ZAI_API_KEY / ZHIPUAI_API_KEY) and
11
+ // call the billing/quota endpoint directly via fetch()
12
+ // The shape below is the contract — replace the body of fetch().
13
+
14
+ import type { QuotaProvider, QuotaSnapshot } from "./types.ts";
15
+
16
+ /** Zhipu AI quota provider.
17
+ *
18
+ * Register in index.ts:
19
+ * registerQuotaProvider(zaiProvider);
20
+ * Then any model with provider="zai" (or "zai-cn") shows the segment. */
21
+ export const zaiProvider: QuotaProvider = {
22
+ id: "zai",
23
+ async fetch(): Promise<QuotaSnapshot | null> {
24
+ // TODO(zai): real implementation. Example shape if using HTTP:
25
+ //
26
+ // const key = process.env.ZAI_API_KEY ?? process.env.ZHIPUAI_API_KEY;
27
+ // if (!key) return null;
28
+ // try {
29
+ // const res = await fetch("https://open.bigmodel.cn/api/paas/v4/billing", {
30
+ // headers: { Authorization: `Bearer ${key}` },
31
+ // });
32
+ // if (!res.ok) return null;
33
+ // const data = await res.json() as { remaining?: number; resetsAt?: string };
34
+ // return {
35
+ // remainingPercent: data.remaining,
36
+ // resetsInMs: data.resetsAt ? Date.parse(data.resetsAt) - Date.now() : null,
37
+ // };
38
+ // } catch {
39
+ // return null; // never throw — poller treats null as "no data"
40
+ // }
41
+ //
42
+ // Until implemented, return null (segment stays hidden for zai models).
43
+ return null;
44
+ },
45
+ };
package/visual/data.ts CHANGED
@@ -45,6 +45,11 @@ export type ChromeData = {
45
45
  recentEvent: string | null;
46
46
  /** Level of the recent event (used for glyph/color). */
47
47
  recentEventLevel: "info" | "warning" | "error" | null;
48
+ /** Remaining quota percent (0–100) for the active provider, polled in
49
+ * the background by quota/poller.ts. null = no adapter / not polled. */
50
+ quotaPercent: number | null;
51
+ /** Human-readable reset time label (e.g. "in 20m"), or null. */
52
+ quotaResetsLabel: string | null;
48
53
  };
49
54
 
50
55
  /** A fresh ChromeData with everything empty/idle. */
@@ -66,5 +71,7 @@ export function emptyChromeData(): ChromeData {
66
71
  artifactCount: 0,
67
72
  recentEvent: null,
68
73
  recentEventLevel: null,
74
+ quotaPercent: null,
75
+ quotaResetsLabel: null,
69
76
  };
70
77
  }
package/visual/footer.ts CHANGED
@@ -71,6 +71,12 @@ export function buildFooterLine(data: ChromeData, fd: FooterData, width: number,
71
71
  left.push({ id: "git", text: styler.fg("muted", withGlyph("git", `${branch}${dirty}`, ascii)), priority: 7 });
72
72
  }
73
73
 
74
+ if (data.quotaPercent !== null) {
75
+ const quotaText = ascii ? `${data.quotaPercent}%` : `⬢ ${data.quotaPercent}%`;
76
+ const label = data.quotaResetsLabel ? `${quotaText} · ${data.quotaResetsLabel}` : quotaText;
77
+ left.push({ id: "quota", text: styler.fg("muted", label), priority: 6 });
78
+ }
79
+
74
80
  if (data.rulesActive > 0) {
75
81
  const word = data.rulesActive === 1 ? "rule" : "rules";
76
82
  const rulesText = ascii ? `${data.rulesActive} ${word}` : `${RULES_GLYPH} ${data.rulesActive}`;