pi-crew 0.9.26 → 0.9.28
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/CHANGELOG.md +102 -0
- package/NOTICE.md +14 -0
- package/assets/crew-vibes.ttf +0 -0
- package/assets/runner-spritesheet.png +0 -0
- package/dist/build-meta.json +245 -48
- package/dist/index.mjs +1611 -243
- package/dist/index.mjs.map +4 -4
- package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
- package/package.json +9 -2
- package/scripts/bench-check.mjs +96 -0
- package/scripts/bench-cold-start.mjs +216 -0
- package/scripts/build-bundle.mjs +84 -0
- package/scripts/build-crew-vibes-font.py +328 -0
- package/scripts/check-all-skills.ts +294 -0
- package/scripts/check-bundle-staleness.mjs +97 -0
- package/scripts/check-conflict-markers.mjs +91 -0
- package/scripts/check-lazy-imports.mjs +28 -0
- package/scripts/install-crew-vibes-font.mjs +91 -0
- package/scripts/postinstall.mjs +47 -0
- package/scripts/profile-startup.mjs +125 -0
- package/scripts/release-smoke.mjs +74 -0
- package/scripts/run-bench.mjs +47 -0
- package/scripts/run-real-chain.ts +41 -0
- package/scripts/test-issue-29-crash.ts +111 -0
- package/scripts/test-issue-29-e2e.ts +183 -0
- package/scripts/test-issue-29-real-runtime.ts +330 -0
- package/scripts/test-issue-29-real-tasks.ts +387 -0
- package/scripts/test-issue-29-team-tool.ts +105 -0
- package/scripts/test-runner.mjs +74 -0
- package/scripts/verify-flicker-fix.ts +109 -0
- package/scripts/verify-skill.ts +550 -0
- package/scripts/watch-bundle.mjs +259 -0
- package/scripts/watchdog-harness.ts +119 -0
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +10 -1
- package/src/config/defaults.ts +6 -0
- package/src/extension/crew-vibes/cat-frames.ts +18 -0
- package/src/extension/crew-vibes/config.ts +195 -0
- package/src/extension/crew-vibes/figures.ts +94 -0
- package/src/extension/crew-vibes/font-detect.ts +58 -0
- package/src/extension/crew-vibes/index.ts +385 -0
- package/src/extension/crew-vibes/provider-usage.ts +396 -0
- package/src/extension/crew-vibes/render.ts +206 -0
- package/src/extension/crew-vibes/speed.ts +286 -0
- package/src/extension/knowledge-injection.ts +12 -3
- package/src/extension/management.ts +23 -3
- package/src/extension/register.ts +7 -0
- package/src/extension/team-tool.ts +11 -0
- package/src/prompt/prompt-runtime.ts +65 -0
- package/src/runtime/child-pi.ts +123 -10
- package/src/runtime/crew-agent-records.ts +10 -3
- package/src/runtime/pi-args.ts +2 -0
- package/src/runtime/retry-executor.ts +4 -1
- package/src/runtime/task-runner/state-helpers.ts +9 -30
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/team-runner.ts +5 -3
- package/src/state/atomic-write.ts +153 -49
- package/src/state/event-log.ts +16 -10
- package/src/state/locks.ts +7 -8
- package/src/state/mailbox.ts +15 -4
- package/src/state/state-store.ts +39 -10
- package/src/teams/discover-teams.ts +56 -1
- package/src/utils/safe-paths.ts +2 -1
- package/src/workflows/discover-workflows.ts +72 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-contained provider rate-limit usage fetcher for crew-vibes.
|
|
3
|
+
*
|
|
4
|
+
* Reads credentials from `~/.pi/agent/auth.json` (plus env overrides) and
|
|
5
|
+
* queries the Anthropic OAuth usage endpoint (primary) and the Copilot
|
|
6
|
+
* internal quota endpoint (secondary). All failure paths return `null`
|
|
7
|
+
* gracefully so headless / credential-less sessions never crash.
|
|
8
|
+
*
|
|
9
|
+
* No external dependencies — only Node.js built-ins (fs, path, os).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export interface ProviderUsage {
|
|
17
|
+
providerName: string;
|
|
18
|
+
fiveHourPercent: number;
|
|
19
|
+
fiveHourResetAt: string | null;
|
|
20
|
+
weeklyPercent: number;
|
|
21
|
+
weeklyResetAt: string | null;
|
|
22
|
+
copilotMonthlyPercent?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** AbortController + setTimeout helper. Always clears the timer. */
|
|
26
|
+
function withTimeout<T>(ms: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timeoutId = setTimeout(() => controller.abort(), ms);
|
|
29
|
+
return fn(controller.signal).finally(() => clearTimeout(timeoutId));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Credential loading
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function piAuthPath(): string {
|
|
37
|
+
return join(homedir(), ".pi", "agent", "auth.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Load the Anthropic OAuth access token from auth.json or env override. */
|
|
41
|
+
export function loadAnthropicToken(): string | undefined {
|
|
42
|
+
const envToken = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
|
|
43
|
+
if (envToken) return envToken;
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(readFileSync(piAuthPath(), "utf8")) as {
|
|
46
|
+
anthropic?: { access?: string };
|
|
47
|
+
};
|
|
48
|
+
const token = data.anthropic?.access;
|
|
49
|
+
return typeof token === "string" && token.length > 0 ? token : undefined;
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Load the z.ai API key from env or auth.json. */
|
|
56
|
+
export function loadZaiToken(): string | undefined {
|
|
57
|
+
const envKey = process.env.ZAI_API_KEY?.trim() || process.env.Z_AI_API_KEY?.trim();
|
|
58
|
+
if (envKey) return envKey;
|
|
59
|
+
try {
|
|
60
|
+
const data = JSON.parse(readFileSync(piAuthPath(), "utf8")) as {
|
|
61
|
+
"z-ai"?: { access?: string; key?: string };
|
|
62
|
+
zai?: { access?: string; key?: string };
|
|
63
|
+
};
|
|
64
|
+
const key = data["z-ai"]?.access || data["z-ai"]?.key || data.zai?.access || data.zai?.key;
|
|
65
|
+
return typeof key === "string" && key.length > 0 ? key : undefined;
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Load the Minimax API key from env or auth.json. */
|
|
72
|
+
export function loadMinimaxToken(): string | undefined {
|
|
73
|
+
const envKey = process.env.MINIMAX_API_KEY?.trim();
|
|
74
|
+
if (envKey) return envKey;
|
|
75
|
+
try {
|
|
76
|
+
const data = JSON.parse(readFileSync(piAuthPath(), "utf8")) as {
|
|
77
|
+
minimax?: { key?: string };
|
|
78
|
+
};
|
|
79
|
+
const key = data.minimax?.key;
|
|
80
|
+
return typeof key === "string" && key.length > 0 ? key : undefined;
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Copilot host entry keys used by the legacy GitHub Copilot CLI. */
|
|
87
|
+
type CopilotHostEntry = {
|
|
88
|
+
oauth_token?: string;
|
|
89
|
+
user_token?: string;
|
|
90
|
+
github_token?: string;
|
|
91
|
+
token?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const COPILOT_TOKEN_KEYS: Array<keyof CopilotHostEntry> = ["oauth_token", "user_token", "github_token", "token"];
|
|
95
|
+
|
|
96
|
+
function tokenFromHostEntry(entry: CopilotHostEntry | undefined): string | undefined {
|
|
97
|
+
if (!entry) return undefined;
|
|
98
|
+
for (const key of COPILOT_TOKEN_KEYS) {
|
|
99
|
+
const value = entry[key];
|
|
100
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Scan legacy Copilot hosts.json locations for a usable token. */
|
|
106
|
+
function loadLegacyCopilotToken(): string | undefined {
|
|
107
|
+
const configHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
108
|
+
const candidates = [join(configHome, "github-copilot", "hosts.json"), join(homedir(), ".github-copilot", "hosts.json")];
|
|
109
|
+
for (const hostsPath of candidates) {
|
|
110
|
+
try {
|
|
111
|
+
const data = JSON.parse(readFileSync(hostsPath, "utf8")) as Record<string, CopilotHostEntry>;
|
|
112
|
+
if (!data || typeof data !== "object") continue;
|
|
113
|
+
const normalized: Record<string, CopilotHostEntry> = {};
|
|
114
|
+
for (const [host, entry] of Object.entries(data)) {
|
|
115
|
+
normalized[host.toLowerCase()] = entry;
|
|
116
|
+
}
|
|
117
|
+
const preferred = tokenFromHostEntry(normalized["github.com"]) ?? tokenFromHostEntry(normalized["api.github.com"]);
|
|
118
|
+
if (preferred) return preferred;
|
|
119
|
+
for (const entry of Object.values(normalized)) {
|
|
120
|
+
const token = tokenFromHostEntry(entry);
|
|
121
|
+
if (token) return token;
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Ignore parse / read errors — try next path
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Load the Copilot token from auth.json, env vars, then legacy locations. */
|
|
131
|
+
export function loadCopilotToken(): string | undefined {
|
|
132
|
+
const envToken = (process.env.COPILOT_GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "").trim();
|
|
133
|
+
if (envToken) return envToken;
|
|
134
|
+
try {
|
|
135
|
+
const data = JSON.parse(readFileSync(piAuthPath(), "utf8")) as {
|
|
136
|
+
"github-copilot"?: { refresh?: string; access?: string };
|
|
137
|
+
};
|
|
138
|
+
const piToken = data["github-copilot"]?.refresh || data["github-copilot"]?.access;
|
|
139
|
+
if (typeof piToken === "string" && piToken.length > 0) return piToken;
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore parse / read errors
|
|
142
|
+
}
|
|
143
|
+
return loadLegacyCopilotToken();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Provider fetches
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/** Anthropic OAuth usage response shape (subset we consume). */
|
|
151
|
+
type AnthropicUsageResponse = {
|
|
152
|
+
five_hour?: { utilization?: number; resets_at?: string };
|
|
153
|
+
seven_day?: { utilization?: number; resets_at?: string };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
async function fetchAnthropicUsage(
|
|
157
|
+
token: string,
|
|
158
|
+
): Promise<Pick<ProviderUsage, "fiveHourPercent" | "weeklyPercent" | "fiveHourResetAt" | "weeklyResetAt">> {
|
|
159
|
+
const data = await withTimeout(10000, async (signal) => {
|
|
160
|
+
const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
161
|
+
headers: {
|
|
162
|
+
Authorization: `Bearer ${token}`,
|
|
163
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
164
|
+
},
|
|
165
|
+
signal,
|
|
166
|
+
});
|
|
167
|
+
if (!res.ok) throw new Error(`anthropic usage HTTP ${res.status}`);
|
|
168
|
+
return (await res.json()) as AnthropicUsageResponse;
|
|
169
|
+
});
|
|
170
|
+
return {
|
|
171
|
+
fiveHourPercent: data.five_hour?.utilization ?? 0,
|
|
172
|
+
weeklyPercent: data.seven_day?.utilization ?? 0,
|
|
173
|
+
fiveHourResetAt: data.five_hour?.resets_at ?? null,
|
|
174
|
+
weeklyResetAt: data.seven_day?.resets_at ?? null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Copilot internal user quota response shape (subset we consume). */
|
|
179
|
+
type CopilotUserResponse = {
|
|
180
|
+
quota_snapshots?: {
|
|
181
|
+
premium_interactions?: { percent_remaining?: number };
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
async function fetchCopilotMonthlyPercent(token: string): Promise<number | undefined> {
|
|
186
|
+
const data = await withTimeout(10000, async (signal) => {
|
|
187
|
+
const res = await fetch("https://api.github.com/copilot_internal/user", {
|
|
188
|
+
headers: {
|
|
189
|
+
Authorization: `token ${token}`,
|
|
190
|
+
"Editor-Version": "vscode/1.96.2",
|
|
191
|
+
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
192
|
+
"X-Github-Api-Version": "2025-04-01",
|
|
193
|
+
Accept: "application/json",
|
|
194
|
+
},
|
|
195
|
+
signal,
|
|
196
|
+
});
|
|
197
|
+
if (!res.ok) throw new Error(`copilot user HTTP ${res.status}`);
|
|
198
|
+
return (await res.json()) as CopilotUserResponse;
|
|
199
|
+
});
|
|
200
|
+
const percentRemaining = data.quota_snapshots?.premium_interactions?.percent_remaining;
|
|
201
|
+
if (typeof percentRemaining !== "number") return undefined;
|
|
202
|
+
return Math.max(0, 100 - percentRemaining);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** z.ai quota limit response shape. */
|
|
206
|
+
type ZaiLimit = {
|
|
207
|
+
type?: string;
|
|
208
|
+
percentage?: number;
|
|
209
|
+
nextResetTime?: string;
|
|
210
|
+
};
|
|
211
|
+
type ZaiUsageResponse = {
|
|
212
|
+
success?: boolean;
|
|
213
|
+
code?: number;
|
|
214
|
+
msg?: string;
|
|
215
|
+
data?: { limits?: ZaiLimit[] };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
async function fetchZaiUsage(token: string): Promise<ProviderUsage> {
|
|
219
|
+
const data = await withTimeout(10000, async (signal) => {
|
|
220
|
+
const res = await fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
|
|
221
|
+
method: "GET",
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${token}`,
|
|
224
|
+
Accept: "application/json",
|
|
225
|
+
},
|
|
226
|
+
signal,
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) throw new Error(`z.ai usage HTTP ${res.status}`);
|
|
229
|
+
return (await res.json()) as ZaiUsageResponse;
|
|
230
|
+
});
|
|
231
|
+
if (!data.success || data.code !== 200) throw new Error(data.msg || "z.ai API error");
|
|
232
|
+
|
|
233
|
+
const limits = data.data?.limits ?? [];
|
|
234
|
+
let tokensPercent = 0;
|
|
235
|
+
let monthlyPercent = 0;
|
|
236
|
+
let tokensResetAt: string | null = null;
|
|
237
|
+
let monthlyResetAt: string | null = null;
|
|
238
|
+
|
|
239
|
+
for (const limit of limits) {
|
|
240
|
+
const pct = limit.percentage ?? 0;
|
|
241
|
+
// nextResetTime from z.ai is epoch ms (number) — convert to ISO string
|
|
242
|
+
const resetIso =
|
|
243
|
+
typeof limit.nextResetTime === "number"
|
|
244
|
+
? new Date(limit.nextResetTime).toISOString()
|
|
245
|
+
: typeof limit.nextResetTime === "string"
|
|
246
|
+
? limit.nextResetTime
|
|
247
|
+
: null;
|
|
248
|
+
|
|
249
|
+
if (limit.type === "TOKENS_LIMIT") {
|
|
250
|
+
tokensPercent = pct;
|
|
251
|
+
tokensResetAt = resetIso;
|
|
252
|
+
} else if (limit.type === "TIME_LIMIT") {
|
|
253
|
+
monthlyPercent = pct;
|
|
254
|
+
monthlyResetAt = resetIso;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
providerName: "z.ai",
|
|
260
|
+
fiveHourPercent: tokensPercent,
|
|
261
|
+
fiveHourResetAt: tokensResetAt,
|
|
262
|
+
weeklyPercent: monthlyPercent,
|
|
263
|
+
weeklyResetAt: monthlyResetAt,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Cache + public entry point
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
let cachedUsage: ProviderUsage | null = null;
|
|
272
|
+
let cachedAt = 0;
|
|
273
|
+
|
|
274
|
+
/** Reset the module-level cache (used by tests and on session restart). */
|
|
275
|
+
export function clearProviderUsageCache(): void {
|
|
276
|
+
cachedUsage = null;
|
|
277
|
+
cachedAt = 0;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Minimax token plan remains response shape. */
|
|
281
|
+
type MinimaxModelRemain = {
|
|
282
|
+
model_name?: string;
|
|
283
|
+
current_interval_remaining_percent?: number;
|
|
284
|
+
current_weekly_remaining_percent?: number;
|
|
285
|
+
end_time?: number;
|
|
286
|
+
weekly_end_time?: number;
|
|
287
|
+
};
|
|
288
|
+
type MinimaxUsageResponse = {
|
|
289
|
+
model_remains?: MinimaxModelRemain[];
|
|
290
|
+
base_resp?: { status_code?: number; status_msg?: string };
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
async function fetchMinimaxUsage(token: string): Promise<ProviderUsage> {
|
|
294
|
+
const data = await withTimeout(10000, async (signal) => {
|
|
295
|
+
const res = await fetch("https://www.minimax.io/v1/token_plan/remains", {
|
|
296
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
297
|
+
signal,
|
|
298
|
+
});
|
|
299
|
+
if (!res.ok) throw new Error(`minimax usage HTTP ${res.status}`);
|
|
300
|
+
return (await res.json()) as MinimaxUsageResponse;
|
|
301
|
+
});
|
|
302
|
+
if (data.base_resp?.status_code !== 0) throw new Error(data.base_resp?.status_msg || "minimax API error");
|
|
303
|
+
|
|
304
|
+
// Find the "general" model (text/chat). Fall back to first model.
|
|
305
|
+
const models = data.model_remains ?? [];
|
|
306
|
+
const general = models.find((m) => m.model_name === "general") ?? models[0];
|
|
307
|
+
if (!general) throw new Error("minimax: no model data");
|
|
308
|
+
|
|
309
|
+
// remaining_percent → used percent
|
|
310
|
+
const intervalUsed = 100 - (general.current_interval_remaining_percent ?? 100);
|
|
311
|
+
const weeklyUsed = 100 - (general.current_weekly_remaining_percent ?? 100);
|
|
312
|
+
|
|
313
|
+
const intervalReset = typeof general.end_time === "number" ? new Date(general.end_time).toISOString() : null;
|
|
314
|
+
const weeklyReset = typeof general.weekly_end_time === "number" ? new Date(general.weekly_end_time).toISOString() : null;
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
providerName: "Minimax",
|
|
318
|
+
fiveHourPercent: intervalUsed,
|
|
319
|
+
fiveHourResetAt: intervalReset,
|
|
320
|
+
weeklyPercent: weeklyUsed,
|
|
321
|
+
weeklyResetAt: weeklyReset,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Providers that have a quota API. */
|
|
326
|
+
const QUOTA_PROVIDERS = new Set(["anthropic", "minimax", "minimax-cn", "zai", "github-copilot"]);
|
|
327
|
+
|
|
328
|
+
/** Check if a provider supports quota checking. */
|
|
329
|
+
export function providerSupportsQuota(provider: string): boolean {
|
|
330
|
+
return QUOTA_PROVIDERS.has(provider);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Fetch usage for a SPECIFIC provider. Returns null if not supported. */
|
|
334
|
+
async function fetchForProvider(provider: string): Promise<ProviderUsage | null> {
|
|
335
|
+
switch (provider) {
|
|
336
|
+
case "anthropic": {
|
|
337
|
+
const token = loadAnthropicToken();
|
|
338
|
+
if (!token) return null;
|
|
339
|
+
const base = await fetchAnthropicUsage(token);
|
|
340
|
+
return { providerName: "Claude", ...base };
|
|
341
|
+
}
|
|
342
|
+
case "minimax":
|
|
343
|
+
case "minimax-cn": {
|
|
344
|
+
const token = loadMinimaxToken();
|
|
345
|
+
if (!token) return null;
|
|
346
|
+
return await fetchMinimaxUsage(token);
|
|
347
|
+
}
|
|
348
|
+
case "zai": {
|
|
349
|
+
const token = loadZaiToken();
|
|
350
|
+
if (!token) return null;
|
|
351
|
+
const usage = await fetchZaiUsage(token);
|
|
352
|
+
usage.providerName = "z.ai";
|
|
353
|
+
return usage;
|
|
354
|
+
}
|
|
355
|
+
case "github-copilot": {
|
|
356
|
+
const token = loadCopilotToken();
|
|
357
|
+
if (!token) return null;
|
|
358
|
+
const pct = await fetchCopilotMonthlyPercent(token);
|
|
359
|
+
if (pct === undefined) return null;
|
|
360
|
+
return {
|
|
361
|
+
providerName: "Copilot",
|
|
362
|
+
fiveHourPercent: 0,
|
|
363
|
+
fiveHourResetAt: null,
|
|
364
|
+
weeklyPercent: pct,
|
|
365
|
+
weeklyResetAt: null,
|
|
366
|
+
copilotMonthlyPercent: pct,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
default:
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Fetch provider rate-limit usage for the current model's provider.
|
|
376
|
+
* Returns `null` when provider has no quota API or no credentials.
|
|
377
|
+
*/
|
|
378
|
+
export async function fetchProviderUsage(maxAgeMs = 300000, provider?: string): Promise<ProviderUsage | null> {
|
|
379
|
+
if (!provider || !QUOTA_PROVIDERS.has(provider)) {
|
|
380
|
+
cachedUsage = null;
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
if (cachedUsage !== null && Date.now() - cachedAt < maxAgeMs) {
|
|
384
|
+
return cachedUsage;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const usage = await fetchForProvider(provider);
|
|
388
|
+
if (usage) {
|
|
389
|
+
cachedUsage = usage;
|
|
390
|
+
cachedAt = Date.now();
|
|
391
|
+
}
|
|
392
|
+
return usage;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CrewTheme } from "../../ui/theme-adapter.ts";
|
|
3
|
+
import {
|
|
4
|
+
CAPACITY_STATUS_ID,
|
|
5
|
+
type CapacityConfig,
|
|
6
|
+
type CrewVibesConfig,
|
|
7
|
+
capacityIcons,
|
|
8
|
+
PROVIDER_STATUS_ID,
|
|
9
|
+
SPEED_STATUS_ID,
|
|
10
|
+
type SpeedConfig,
|
|
11
|
+
type TokenDisplay,
|
|
12
|
+
} from "./config.ts";
|
|
13
|
+
import { capacityIndex, crewFrames, isDangerStage } from "./figures.ts";
|
|
14
|
+
|
|
15
|
+
export type CapacityUsage = {
|
|
16
|
+
tokens: number | null;
|
|
17
|
+
percent: number | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function formatCount(value: number): string {
|
|
21
|
+
if (value < 1000) return value.toString();
|
|
22
|
+
if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
|
|
23
|
+
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
|
|
24
|
+
if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
25
|
+
return `${Math.round(value / 1_000_000)}M`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function asCrewTheme(theme: unknown): CrewTheme | undefined {
|
|
29
|
+
if (theme && typeof theme === "object" && typeof (theme as CrewTheme).fg === "function") {
|
|
30
|
+
return theme as CrewTheme;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getCapacityUsage(ctx: ExtensionContext): CapacityUsage {
|
|
36
|
+
const fn = (ctx as { getContextUsage?: () => { tokens?: number; percent?: number } | null }).getContextUsage;
|
|
37
|
+
const usage = typeof fn === "function" ? fn.call(ctx) : null;
|
|
38
|
+
return {
|
|
39
|
+
tokens: typeof usage?.tokens === "number" && Number.isFinite(usage.tokens) ? usage.tokens : null,
|
|
40
|
+
percent: typeof usage?.percent === "number" && Number.isFinite(usage.percent) ? usage.percent : null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatSpeed(config: SpeedConfig, speed: number | null): string {
|
|
45
|
+
return speed === null ? `-- ${config.label}` : `${speed.toFixed(1)} ${config.label}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderSpeedFooter(theme: CrewTheme | undefined, config: SpeedConfig, speed: number | null): string {
|
|
49
|
+
const value = speed === null ? "--" : speed.toFixed(1);
|
|
50
|
+
const valueTone = speed === null ? "dim" : "accent";
|
|
51
|
+
const styled = theme ? `${theme.fg(valueTone, value)} ${theme.fg("dim", config.label)}` : `${value} ${config.label}`;
|
|
52
|
+
return styled;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderWorkingMessage(theme: CrewTheme | undefined, config: SpeedConfig, speed: number | null): string {
|
|
56
|
+
const left = "Working";
|
|
57
|
+
const speedText = theme
|
|
58
|
+
? `${theme.fg(speed === null ? "dim" : "accent", speed === null ? "--" : speed.toFixed(1))} ${theme.fg("dim", config.label)}`
|
|
59
|
+
: `${speed === null ? "--" : speed.toFixed(1)} ${config.label}`;
|
|
60
|
+
return theme ? `${theme.fg("muted", left)} ${speedText}` : `${left} ${speedText}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function crewIndicatorFrames(theme: CrewTheme | undefined): string[] {
|
|
64
|
+
const frames = crewFrames();
|
|
65
|
+
if (!theme) return [...frames];
|
|
66
|
+
return frames.map((frame) => theme.fg("accent", frame));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatCapacityPrefix(config: CapacityConfig, usage: CapacityUsage): string {
|
|
70
|
+
const display: TokenDisplay = config.tokenDisplay;
|
|
71
|
+
if (display === "off") return "";
|
|
72
|
+
if (display === "percentage") {
|
|
73
|
+
return `${usage.percent === null ? "?" : Math.round(Math.max(0, Math.min(999, usage.percent)))}% `;
|
|
74
|
+
}
|
|
75
|
+
return `${usage.tokens === null ? "?" : formatCount(usage.tokens)} `;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function colorStage(theme: CrewTheme | undefined, index: number, levels: number, text: string): string {
|
|
79
|
+
if (!theme || text.length === 0) return text;
|
|
80
|
+
return theme.fg(isDangerStage(index, levels) ? "error" : "success", text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderCapacity(theme: CrewTheme | undefined, config: CapacityConfig, usage: CapacityUsage): string {
|
|
84
|
+
const icons = capacityIcons();
|
|
85
|
+
const levels = icons.length;
|
|
86
|
+
const index = capacityIndex(usage.percent, levels);
|
|
87
|
+
const icon = icons[index] ?? icons[0];
|
|
88
|
+
const label = config.labels[index] ?? config.labels[0];
|
|
89
|
+
const prefix = theme ? theme.fg("muted", formatCapacityPrefix(config, usage)) : formatCapacityPrefix(config, usage);
|
|
90
|
+
const coloredIcon = colorStage(theme, index, levels, icon);
|
|
91
|
+
const afterIcon = config.showLabel ? ` ${colorStage(theme, index, levels, label)}` : " ";
|
|
92
|
+
return `${prefix}${coloredIcon}${afterIcon}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function setSpeedStatus(ctx: ExtensionContext, config: CrewVibesConfig, text: string | undefined): void {
|
|
96
|
+
if (!ctx?.hasUI) return;
|
|
97
|
+
if (!config.enabled || !config.speed.enabled || !config.speed.footer) {
|
|
98
|
+
ctx.ui.setStatus(SPEED_STATUS_ID, undefined);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
ctx.ui.setStatus(SPEED_STATUS_ID, text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function setCapacityStatus(ctx: ExtensionContext, config: CrewVibesConfig, text: string | undefined): void {
|
|
105
|
+
if (!ctx?.hasUI) return;
|
|
106
|
+
if (!config.enabled || !config.capacity.enabled) {
|
|
107
|
+
ctx.ui.setStatus(CAPACITY_STATUS_ID, undefined);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
ctx.ui.setStatus(CAPACITY_STATUS_ID, text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function clearVibesStatus(ctx: ExtensionContext): void {
|
|
114
|
+
if (!ctx?.hasUI) return;
|
|
115
|
+
ctx.ui.setStatus(SPEED_STATUS_ID, undefined);
|
|
116
|
+
ctx.ui.setStatus(CAPACITY_STATUS_ID, undefined);
|
|
117
|
+
ctx.ui.setStatus(PROVIDER_STATUS_ID, undefined);
|
|
118
|
+
if (ctx.ui.setWorkingIndicator) ctx.ui.setWorkingIndicator();
|
|
119
|
+
if (ctx.ui.setWorkingMessage) ctx.ui.setWorkingMessage();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Provider rate-limit usage snapshot (mirrors provider-usage.ts interface).
|
|
123
|
+
// Defined locally to avoid a phase dependency on provider-usage.ts.
|
|
124
|
+
export type ProviderUsage = {
|
|
125
|
+
providerName: string;
|
|
126
|
+
fiveHourPercent: number;
|
|
127
|
+
fiveHourResetAt: string | null;
|
|
128
|
+
weeklyPercent: number;
|
|
129
|
+
weeklyResetAt: string | null;
|
|
130
|
+
copilotMonthlyPercent?: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Format a time-until-reset duration as a compact `2h30m` / `45m` / `3h` string. */
|
|
134
|
+
function formatResetTimer(resetAt: string | null): string | null {
|
|
135
|
+
if (!resetAt) return null;
|
|
136
|
+
const diffMs = new Date(resetAt).getTime() - Date.now();
|
|
137
|
+
if (diffMs < 0) return null;
|
|
138
|
+
const mins = Math.floor(diffMs / 60000);
|
|
139
|
+
if (mins < 60) return `${mins}m`;
|
|
140
|
+
const hours = Math.floor(mins / 60);
|
|
141
|
+
if (hours < 48) {
|
|
142
|
+
const remMins = mins % 60;
|
|
143
|
+
return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`;
|
|
144
|
+
}
|
|
145
|
+
const days = Math.floor(hours / 24);
|
|
146
|
+
const remHours = hours % 24;
|
|
147
|
+
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Render provider rate-limit usage as a compact status string with bars.
|
|
151
|
+
// Returns `undefined` when there is nothing to show (null usage).
|
|
152
|
+
|
|
153
|
+
/** Render a progress bar using heavy line characters (matches pi-sub-bar style).
|
|
154
|
+
* `━━━━━━┄┄┄┄` for 60% — filled uses ━ (U+2501), empty uses ┄ (U+2504). */
|
|
155
|
+
function renderBar(percent: number, width = 8): string {
|
|
156
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
157
|
+
const filled = Math.round((clamped / 100) * width);
|
|
158
|
+
return `${"\u2501".repeat(filled)}${"\u2504".repeat(width - filled)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function renderProviderUsage(theme: CrewTheme | undefined, usage: ProviderUsage | null): string | undefined {
|
|
162
|
+
if (!usage) return undefined;
|
|
163
|
+
|
|
164
|
+
const parts: string[] = [];
|
|
165
|
+
|
|
166
|
+
// Provider name — muted/bold
|
|
167
|
+
if (usage.providerName) {
|
|
168
|
+
const nameText = usage.providerName;
|
|
169
|
+
parts.push(theme ? theme.fg("muted", nameText) : nameText);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 5h window — error color at 80%+, accent otherwise
|
|
173
|
+
const fiveHourBar = renderBar(usage.fiveHourPercent);
|
|
174
|
+
const fiveHourRounded = Math.round(usage.fiveHourPercent);
|
|
175
|
+
const fiveHourReset = formatResetTimer(usage.fiveHourResetAt);
|
|
176
|
+
const fiveHourText = `5h ${fiveHourBar} ${fiveHourRounded}%${fiveHourReset ? " " + fiveHourReset : ""}`;
|
|
177
|
+
const fiveHourColor = usage.fiveHourPercent >= 80 ? "error" : "accent";
|
|
178
|
+
parts.push(theme ? theme.fg(fiveHourColor, fiveHourText) : fiveHourText);
|
|
179
|
+
|
|
180
|
+
// Weekly window — dim
|
|
181
|
+
const weeklyBar = renderBar(usage.weeklyPercent);
|
|
182
|
+
const weeklyRounded = Math.round(usage.weeklyPercent);
|
|
183
|
+
const weeklyReset = formatResetTimer(usage.weeklyResetAt);
|
|
184
|
+
const weeklyText = `Wk ${weeklyBar} ${weeklyRounded}%${weeklyReset ? " " + weeklyReset : ""}`;
|
|
185
|
+
parts.push(theme ? theme.fg("dim", weeklyText) : weeklyText);
|
|
186
|
+
|
|
187
|
+
// Copilot monthly — dim (optional)
|
|
188
|
+
if (typeof usage.copilotMonthlyPercent === "number" && Number.isFinite(usage.copilotMonthlyPercent)) {
|
|
189
|
+
const monthlyRounded = Math.round(usage.copilotMonthlyPercent);
|
|
190
|
+
const monthlyText = `Mo: ${monthlyRounded}%`;
|
|
191
|
+
parts.push(theme ? theme.fg("dim", monthlyText) : monthlyText);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return parts.join(" ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function setProviderStatus(ctx: ExtensionContext, config: CrewVibesConfig, text: string | undefined): void {
|
|
198
|
+
if (!ctx?.hasUI) return;
|
|
199
|
+
if (!config.enabled || !config.capacity.providerUsage) {
|
|
200
|
+
ctx.ui.setStatus(PROVIDER_STATUS_ID, undefined);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
ctx.ui.setStatus(PROVIDER_STATUS_ID, text);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { asCrewTheme };
|