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 +24 -3
- package/mcp/config.ts +3 -2
- package/mcp/direct-tools.ts +5 -4
- package/mcp/ext-apps-bridge.ts +6 -3
- package/mcp/host-html-template.ts +2 -1
- package/mcp/index.ts +6 -6
- package/mcp/init.ts +1 -1
- package/mcp/logger.ts +12 -7
- package/mcp/mcp-auth-flow.ts +7 -6
- package/mcp/mcp-auth.ts +3 -2
- package/package.json +2 -1
- package/quota/format.ts +23 -0
- package/quota/minimax.ts +81 -0
- package/quota/poller.ts +99 -0
- package/quota/registry.ts +39 -0
- package/quota/types.ts +37 -0
- package/quota/zai.ts +45 -0
- package/visual/data.ts +7 -0
- package/visual/footer.ts +6 -0
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
|
-
|
|
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
|
-
|
|
980
|
+
emit("[soly] MCP adapter unavailable (load failed):" + ": " + (err instanceof Error ? err.message : String(err)), "error");
|
|
960
981
|
});
|
|
961
982
|
} catch (err) {
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
emit(`Failed to load ${label}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
320
321
|
return null;
|
|
321
322
|
}
|
|
322
323
|
}
|
package/mcp/direct-tools.ts
CHANGED
|
@@ -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
|
-
|
|
137
|
+
emit(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`, "warning");
|
|
137
138
|
continue;
|
|
138
139
|
}
|
|
139
140
|
if (seenNames.has(prefixedName)) {
|
|
140
|
-
|
|
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
|
-
|
|
163
|
+
emit(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`, "warning");
|
|
163
164
|
continue;
|
|
164
165
|
}
|
|
165
166
|
if (seenNames.has(prefixedName)) {
|
|
166
|
-
|
|
167
|
+
emit(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`, "warning");
|
|
167
168
|
continue;
|
|
168
169
|
}
|
|
169
170
|
seenNames.add(prefixedName);
|
package/mcp/ext-apps-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
47
|
-
"
|
|
48
|
-
"(upstream ext-apps/sdk version mismatch, not a soly bug). Core MCP
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
77
|
-
|
|
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
|
|
82
|
+
const errStr = error ? `: ${error.message}` : "";
|
|
83
|
+
const text = contextStr ? `${message}${errStr} ${contextStr}` : `${message}${errStr}`;
|
|
80
84
|
|
|
81
85
|
if (level === "error") {
|
|
82
|
-
|
|
86
|
+
emitEvent(text, "error");
|
|
83
87
|
} else if (level === "warn") {
|
|
84
|
-
|
|
88
|
+
emitEvent(text, "warning");
|
|
85
89
|
} else if (level === "debug") {
|
|
86
|
-
|
|
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
|
-
|
|
93
|
+
emitEvent(text, "info");
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
// Custom handlers
|
package/mcp/mcp-auth-flow.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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": [
|
package/quota/format.ts
ADDED
|
@@ -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
|
+
}
|
package/quota/minimax.ts
ADDED
|
@@ -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
|
+
};
|
package/quota/poller.ts
ADDED
|
@@ -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}`;
|