@jameslovespancakes/pi-plus 1.0.0 → 1.0.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/LICENSE +21 -21
- package/README.md +190 -190
- package/config/pi-plus.example.json +60 -60
- package/config/skills/model-routing/SKILL.md +86 -86
- package/images/pi-plus.svg +10 -10
- package/package.json +67 -67
- package/server/board-server.mjs +641 -641
- package/server/package.json +17 -17
- package/src/core/accounts/registry.ts +93 -93
- package/src/core/anthropic/client-identity.ts +241 -241
- package/src/core/catalog/quality.ts +314 -314
- package/src/core/config.ts +169 -169
- package/src/core/env.ts +58 -58
- package/src/core/exec/process.ts +146 -146
- package/src/core/exec/ssh-config.ts +157 -157
- package/src/core/policy/policy.ts +183 -183
- package/src/core/quota/pool.ts +64 -64
- package/src/core/quota/usage-source.ts +289 -289
- package/src/core/store.ts +43 -43
- package/src/domains/agents/board-setup.ts +409 -409
- package/src/domains/agents/index.ts +462 -462
- package/src/domains/models/catalog-tool.ts +361 -361
- package/src/domains/models/index.ts +14 -14
- package/src/domains/models/policy-gate.ts +169 -169
- package/src/domains/models/provider-picker.ts +207 -207
- package/src/domains/remote/config-path.ts +41 -41
- package/src/domains/remote/index.ts +866 -866
- package/src/domains/remote/setup.ts +425 -425
- package/src/domains/setup/index.ts +220 -220
- package/src/domains/subscriptions/accounts.ts +242 -242
- package/src/domains/subscriptions/footer.ts +182 -182
- package/src/domains/subscriptions/index.ts +42 -42
- package/src/domains/subscriptions/provider.ts +219 -219
- package/src/domains/subscriptions/providers/anthropic.ts +149 -149
- package/src/domains/subscriptions/providers/codex.ts +148 -148
- package/src/domains/subscriptions/routing.ts +72 -72
- package/src/services/usage-service.ts +186 -186
- package/src/ui/format.ts +73 -73
- package/src/ui/usage-bars.ts +154 -154
- package/src/vendor/anthropic.ts +109 -109
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { accountProvider, routableProviders, type RoutingMode } from "../../core/accounts/registry.ts";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* `/routing [standard|optimal] [provider]`
|
|
6
|
-
*
|
|
7
|
-
* standard main account first, fall back only when exhausted
|
|
8
|
-
* optimal balance across accounts by remaining quota and time to reset
|
|
9
|
-
*
|
|
10
|
-
* With no provider named, the mode is applied to every provider that supports
|
|
11
|
-
* routing. With no mode, the current modes are reported.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const MODES: RoutingMode[] = ["standard", "optimal"];
|
|
15
|
-
|
|
16
|
-
function isMode(value: string): value is RoutingMode {
|
|
17
|
-
return (MODES as string[]).includes(value);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function registerRoutingCommands(pi: ExtensionAPI): void {
|
|
21
|
-
pi.registerCommand("routing", {
|
|
22
|
-
description: "Account routing: standard (main first) or optimal (quota balanced)",
|
|
23
|
-
getArgumentCompletions: (prefix) =>
|
|
24
|
-
MODES.filter((mode) => mode.startsWith(prefix)).map((mode) => ({
|
|
25
|
-
value: mode,
|
|
26
|
-
label: mode === "optimal" ? "optimal: balance by remaining quota" : "standard: main account first",
|
|
27
|
-
})),
|
|
28
|
-
handler: async (args, ctx) => {
|
|
29
|
-
const [first, second] = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
30
|
-
const providers = second ? [accountProvider(second)].filter((entry) => !!entry) : routableProviders();
|
|
31
|
-
|
|
32
|
-
if (providers.length === 0) {
|
|
33
|
-
ctx.ui.notify(
|
|
34
|
-
second ? `“${second}” does not support account routing.` : "No providers support account routing.",
|
|
35
|
-
"warning",
|
|
36
|
-
);
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// No mode given: report current state.
|
|
41
|
-
if (!first) {
|
|
42
|
-
const lines: string[] = [];
|
|
43
|
-
for (const provider of providers) {
|
|
44
|
-
try {
|
|
45
|
-
const mode = await provider.routing!.get();
|
|
46
|
-
lines.push(`${provider.label} (${provider.id}): ${mode}`, ` ${provider.routing!.describe(mode)}`);
|
|
47
|
-
} catch (error) {
|
|
48
|
-
lines.push(`${provider.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (!isMode(first)) {
|
|
56
|
-
ctx.ui.notify(`Usage: /routing [standard|optimal] [provider]`, "warning");
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const results: string[] = [];
|
|
61
|
-
for (const provider of providers) {
|
|
62
|
-
try {
|
|
63
|
-
const mode = await provider.routing!.set(first);
|
|
64
|
-
results.push(`${provider.label}: ${mode}`, ` ${provider.routing!.describe(mode)}`);
|
|
65
|
-
} catch (error) {
|
|
66
|
-
results.push(`${provider.label}: failed, ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
ctx.ui.notify(results.join("\n"), "info");
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
}
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { accountProvider, routableProviders, type RoutingMode } from "../../core/accounts/registry.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `/routing [standard|optimal] [provider]`
|
|
6
|
+
*
|
|
7
|
+
* standard main account first, fall back only when exhausted
|
|
8
|
+
* optimal balance across accounts by remaining quota and time to reset
|
|
9
|
+
*
|
|
10
|
+
* With no provider named, the mode is applied to every provider that supports
|
|
11
|
+
* routing. With no mode, the current modes are reported.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MODES: RoutingMode[] = ["standard", "optimal"];
|
|
15
|
+
|
|
16
|
+
function isMode(value: string): value is RoutingMode {
|
|
17
|
+
return (MODES as string[]).includes(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerRoutingCommands(pi: ExtensionAPI): void {
|
|
21
|
+
pi.registerCommand("routing", {
|
|
22
|
+
description: "Account routing: standard (main first) or optimal (quota balanced)",
|
|
23
|
+
getArgumentCompletions: (prefix) =>
|
|
24
|
+
MODES.filter((mode) => mode.startsWith(prefix)).map((mode) => ({
|
|
25
|
+
value: mode,
|
|
26
|
+
label: mode === "optimal" ? "optimal: balance by remaining quota" : "standard: main account first",
|
|
27
|
+
})),
|
|
28
|
+
handler: async (args, ctx) => {
|
|
29
|
+
const [first, second] = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
30
|
+
const providers = second ? [accountProvider(second)].filter((entry) => !!entry) : routableProviders();
|
|
31
|
+
|
|
32
|
+
if (providers.length === 0) {
|
|
33
|
+
ctx.ui.notify(
|
|
34
|
+
second ? `“${second}” does not support account routing.` : "No providers support account routing.",
|
|
35
|
+
"warning",
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// No mode given: report current state.
|
|
41
|
+
if (!first) {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
for (const provider of providers) {
|
|
44
|
+
try {
|
|
45
|
+
const mode = await provider.routing!.get();
|
|
46
|
+
lines.push(`${provider.label} (${provider.id}): ${mode}`, ` ${provider.routing!.describe(mode)}`);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
lines.push(`${provider.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isMode(first)) {
|
|
56
|
+
ctx.ui.notify(`Usage: /routing [standard|optimal] [provider]`, "warning");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const results: string[] = [];
|
|
61
|
+
for (const provider of providers) {
|
|
62
|
+
try {
|
|
63
|
+
const mode = await provider.routing!.set(first);
|
|
64
|
+
results.push(`${provider.label}: ${mode}`, ` ${provider.routing!.describe(mode)}`);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
results.push(`${provider.label}: failed, ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
ctx.ui.notify(results.join("\n"), "info");
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -1,186 +1,186 @@
|
|
|
1
|
-
import { agentPath, readJson, writeJson } from "../core/store.ts";
|
|
2
|
-
import { isClaudeAccount, type UsageRow } from "../core/quota/pool.ts";
|
|
3
|
-
import { fetchAll } from "../core/quota/usage-source.ts";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The single owner of subscription usage state.
|
|
7
|
-
*
|
|
8
|
-
* Previously the only poller lived inside the footer extension and was gated on
|
|
9
|
-
* `ctx.hasUI`, so `list_models` silently read empty rows whenever the footer was
|
|
10
|
-
* hidden or the session was headless (including every workflow subagent).
|
|
11
|
-
* Consumers now call `ensureFresh()` for on-demand data and `subscribe()` for
|
|
12
|
-
* push updates; only one poll is ever in flight regardless of consumer count.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export const REFRESH_MS = 5 * 60 * 1000;
|
|
16
|
-
const MIN_INTERVAL_MS = 90_000;
|
|
17
|
-
const BACKOFF_MS = 10 * 60 * 1000;
|
|
18
|
-
const CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
19
|
-
|
|
20
|
-
export interface UsageState {
|
|
21
|
-
rows: UsageRow[];
|
|
22
|
-
errors: string[];
|
|
23
|
-
updatedAt?: number;
|
|
24
|
-
loading: boolean;
|
|
25
|
-
accounts: number;
|
|
26
|
-
codexPlan?: string;
|
|
27
|
-
/**
|
|
28
|
-
* Last time each account group's quota was observed to drop. This is the only
|
|
29
|
-
* available proxy for "recently used": providers expose remaining quota but
|
|
30
|
-
* never report which account served a request.
|
|
31
|
-
*/
|
|
32
|
-
lastUsedAt?: Record<string, number>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, lastUsedAt: {} };
|
|
36
|
-
const listeners = new Set<() => void>();
|
|
37
|
-
|
|
38
|
-
let nextAllowedFetch = 0;
|
|
39
|
-
let inFlight: Promise<void> | undefined;
|
|
40
|
-
let timer: NodeJS.Timeout | undefined;
|
|
41
|
-
let started = false;
|
|
42
|
-
|
|
43
|
-
function cachePath(): string {
|
|
44
|
-
return agentPath("usage-bar-cache.json");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Seed from the last session so a restart shows figures before the first fetch. */
|
|
48
|
-
function loadCache(): void {
|
|
49
|
-
const cached = readJson<UsageState | undefined>(cachePath(), undefined);
|
|
50
|
-
if (!cached || !Array.isArray(cached.rows) || !cached.updatedAt) return;
|
|
51
|
-
if (Date.now() - cached.updatedAt > CACHE_MAX_AGE_MS) return;
|
|
52
|
-
state.rows = cached.rows.filter((row) => !row.group.startsWith("Claude pool ×"));
|
|
53
|
-
state.accounts = cached.accounts ?? 0;
|
|
54
|
-
state.codexPlan = cached.codexPlan;
|
|
55
|
-
state.updatedAt = cached.updatedAt;
|
|
56
|
-
state.lastUsedAt = cached.lastUsedAt ?? {};
|
|
57
|
-
state.loading = false;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function saveCache(): void {
|
|
61
|
-
writeJson(cachePath(), {
|
|
62
|
-
rows: state.rows,
|
|
63
|
-
accounts: state.accounts,
|
|
64
|
-
codexPlan: state.codexPlan,
|
|
65
|
-
updatedAt: state.updatedAt,
|
|
66
|
-
lastUsedAt: state.lastUsedAt,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
loadCache();
|
|
71
|
-
|
|
72
|
-
function emit(): void {
|
|
73
|
-
for (const listener of listeners) {
|
|
74
|
-
try {
|
|
75
|
-
listener();
|
|
76
|
-
} catch { /* a bad subscriber must not break the poll */ }
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Stamps an account as recently used when its headline window falls. A rise
|
|
82
|
-
* (quota reset) or an unchanged figure is not a usage signal.
|
|
83
|
-
*/
|
|
84
|
-
function recordUsageDrops(fresh: UsageRow[]): void {
|
|
85
|
-
const previous = new Map(
|
|
86
|
-
state.rows.filter((row) => row.label === "5h").map((row) => [row.group, row.remaining]),
|
|
87
|
-
);
|
|
88
|
-
const stamps = { ...(state.lastUsedAt ?? {}) };
|
|
89
|
-
for (const row of fresh) {
|
|
90
|
-
if (row.label !== "5h") continue;
|
|
91
|
-
const before = previous.get(row.group);
|
|
92
|
-
if (before !== undefined && row.remaining < before - 0.01) stamps[row.group] = Date.now();
|
|
93
|
-
}
|
|
94
|
-
state.lastUsedAt = stamps;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function usageState(): UsageState {
|
|
98
|
-
return state;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Account groups ordered by most recent observed use, capped at `limit`.
|
|
103
|
-
* Accounts never seen in use fall back to alphabetical, so the list is stable.
|
|
104
|
-
*/
|
|
105
|
-
export function recentAccounts(limit: number): string[] {
|
|
106
|
-
const stamps = state.lastUsedAt ?? {};
|
|
107
|
-
const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
|
|
108
|
-
return groups
|
|
109
|
-
.sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b))
|
|
110
|
-
.slice(0, limit);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Notified after every state change. Returns an unsubscribe function. */
|
|
114
|
-
export function subscribe(listener: () => void): () => void {
|
|
115
|
-
listeners.add(listener);
|
|
116
|
-
return () => listeners.delete(listener);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* The endpoints are rate limited, so results are cached and refreshes are
|
|
121
|
-
* throttled. Failures keep the previous figures on screen.
|
|
122
|
-
*/
|
|
123
|
-
export async function refreshUsage(ctx: any, force = false): Promise<void> {
|
|
124
|
-
if (inFlight) return inFlight;
|
|
125
|
-
const now = Date.now();
|
|
126
|
-
const dueAt = Math.max(nextAllowedFetch, (state.updatedAt ?? 0) + MIN_INTERVAL_MS);
|
|
127
|
-
if (!force && state.updatedAt && now < dueAt) return;
|
|
128
|
-
|
|
129
|
-
inFlight = (async () => {
|
|
130
|
-
try {
|
|
131
|
-
const result = await fetchAll(ctx);
|
|
132
|
-
const rateLimited = result.errors.some((error) => error.includes("429"));
|
|
133
|
-
recordUsageDrops(result.rows);
|
|
134
|
-
|
|
135
|
-
// Per-account merge: groups that failed this cycle keep their last figures.
|
|
136
|
-
const freshGroups = new Set(result.rows.map((row) => row.group));
|
|
137
|
-
const retained = state.rows
|
|
138
|
-
.filter((row) => !row.group.startsWith("Claude pool ×")
|
|
139
|
-
&& !freshGroups.has(row.group)
|
|
140
|
-
&& (!isClaudeAccount(row) || result.groups.includes(row.group)))
|
|
141
|
-
.map((row) => ({ ...row, stale: true }));
|
|
142
|
-
|
|
143
|
-
state.rows = [...result.rows, ...retained];
|
|
144
|
-
state.accounts = result.groups.length;
|
|
145
|
-
state.updatedAt = Date.now(); // poll time only; rows retain their own checkedAt
|
|
146
|
-
state.errors = result.errors;
|
|
147
|
-
if (result.codexPlan !== undefined) state.codexPlan = result.codexPlan;
|
|
148
|
-
nextAllowedFetch = rateLimited ? Date.now() + BACKOFF_MS : 0;
|
|
149
|
-
saveCache();
|
|
150
|
-
} catch (error) {
|
|
151
|
-
state.errors = [`Usage refresh failed: ${error instanceof Error ? error.message : String(error)}`];
|
|
152
|
-
} finally {
|
|
153
|
-
state.loading = false;
|
|
154
|
-
}
|
|
155
|
-
})().finally(() => {
|
|
156
|
-
inFlight = undefined;
|
|
157
|
-
emit();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
return inFlight;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Guarantees usable data for a caller that does not own the poll loop.
|
|
165
|
-
* This is what makes `list_models` correct in headless sessions.
|
|
166
|
-
*/
|
|
167
|
-
export async function ensureFresh(ctx: any): Promise<UsageState> {
|
|
168
|
-
const stale = !state.updatedAt || Date.now() - state.updatedAt > REFRESH_MS;
|
|
169
|
-
if (stale) await refreshUsage(ctx);
|
|
170
|
-
return state;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Starts the shared interval. Safe to call from multiple domains. */
|
|
174
|
-
export function startPolling(ctx: any): void {
|
|
175
|
-
if (started) return;
|
|
176
|
-
started = true;
|
|
177
|
-
void refreshUsage(ctx);
|
|
178
|
-
timer ??= setInterval(() => void refreshUsage(ctx), REFRESH_MS);
|
|
179
|
-
timer.unref?.();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function stopPolling(): void {
|
|
183
|
-
if (timer) clearInterval(timer);
|
|
184
|
-
timer = undefined;
|
|
185
|
-
started = false;
|
|
186
|
-
}
|
|
1
|
+
import { agentPath, readJson, writeJson } from "../core/store.ts";
|
|
2
|
+
import { isClaudeAccount, type UsageRow } from "../core/quota/pool.ts";
|
|
3
|
+
import { fetchAll } from "../core/quota/usage-source.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The single owner of subscription usage state.
|
|
7
|
+
*
|
|
8
|
+
* Previously the only poller lived inside the footer extension and was gated on
|
|
9
|
+
* `ctx.hasUI`, so `list_models` silently read empty rows whenever the footer was
|
|
10
|
+
* hidden or the session was headless (including every workflow subagent).
|
|
11
|
+
* Consumers now call `ensureFresh()` for on-demand data and `subscribe()` for
|
|
12
|
+
* push updates; only one poll is ever in flight regardless of consumer count.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const REFRESH_MS = 5 * 60 * 1000;
|
|
16
|
+
const MIN_INTERVAL_MS = 90_000;
|
|
17
|
+
const BACKOFF_MS = 10 * 60 * 1000;
|
|
18
|
+
const CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
export interface UsageState {
|
|
21
|
+
rows: UsageRow[];
|
|
22
|
+
errors: string[];
|
|
23
|
+
updatedAt?: number;
|
|
24
|
+
loading: boolean;
|
|
25
|
+
accounts: number;
|
|
26
|
+
codexPlan?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Last time each account group's quota was observed to drop. This is the only
|
|
29
|
+
* available proxy for "recently used": providers expose remaining quota but
|
|
30
|
+
* never report which account served a request.
|
|
31
|
+
*/
|
|
32
|
+
lastUsedAt?: Record<string, number>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const state: UsageState = { rows: [], errors: [], loading: true, accounts: 0, lastUsedAt: {} };
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
|
|
38
|
+
let nextAllowedFetch = 0;
|
|
39
|
+
let inFlight: Promise<void> | undefined;
|
|
40
|
+
let timer: NodeJS.Timeout | undefined;
|
|
41
|
+
let started = false;
|
|
42
|
+
|
|
43
|
+
function cachePath(): string {
|
|
44
|
+
return agentPath("usage-bar-cache.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Seed from the last session so a restart shows figures before the first fetch. */
|
|
48
|
+
function loadCache(): void {
|
|
49
|
+
const cached = readJson<UsageState | undefined>(cachePath(), undefined);
|
|
50
|
+
if (!cached || !Array.isArray(cached.rows) || !cached.updatedAt) return;
|
|
51
|
+
if (Date.now() - cached.updatedAt > CACHE_MAX_AGE_MS) return;
|
|
52
|
+
state.rows = cached.rows.filter((row) => !row.group.startsWith("Claude pool ×"));
|
|
53
|
+
state.accounts = cached.accounts ?? 0;
|
|
54
|
+
state.codexPlan = cached.codexPlan;
|
|
55
|
+
state.updatedAt = cached.updatedAt;
|
|
56
|
+
state.lastUsedAt = cached.lastUsedAt ?? {};
|
|
57
|
+
state.loading = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveCache(): void {
|
|
61
|
+
writeJson(cachePath(), {
|
|
62
|
+
rows: state.rows,
|
|
63
|
+
accounts: state.accounts,
|
|
64
|
+
codexPlan: state.codexPlan,
|
|
65
|
+
updatedAt: state.updatedAt,
|
|
66
|
+
lastUsedAt: state.lastUsedAt,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
loadCache();
|
|
71
|
+
|
|
72
|
+
function emit(): void {
|
|
73
|
+
for (const listener of listeners) {
|
|
74
|
+
try {
|
|
75
|
+
listener();
|
|
76
|
+
} catch { /* a bad subscriber must not break the poll */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stamps an account as recently used when its headline window falls. A rise
|
|
82
|
+
* (quota reset) or an unchanged figure is not a usage signal.
|
|
83
|
+
*/
|
|
84
|
+
function recordUsageDrops(fresh: UsageRow[]): void {
|
|
85
|
+
const previous = new Map(
|
|
86
|
+
state.rows.filter((row) => row.label === "5h").map((row) => [row.group, row.remaining]),
|
|
87
|
+
);
|
|
88
|
+
const stamps = { ...(state.lastUsedAt ?? {}) };
|
|
89
|
+
for (const row of fresh) {
|
|
90
|
+
if (row.label !== "5h") continue;
|
|
91
|
+
const before = previous.get(row.group);
|
|
92
|
+
if (before !== undefined && row.remaining < before - 0.01) stamps[row.group] = Date.now();
|
|
93
|
+
}
|
|
94
|
+
state.lastUsedAt = stamps;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function usageState(): UsageState {
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Account groups ordered by most recent observed use, capped at `limit`.
|
|
103
|
+
* Accounts never seen in use fall back to alphabetical, so the list is stable.
|
|
104
|
+
*/
|
|
105
|
+
export function recentAccounts(limit: number): string[] {
|
|
106
|
+
const stamps = state.lastUsedAt ?? {};
|
|
107
|
+
const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
|
|
108
|
+
return groups
|
|
109
|
+
.sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b))
|
|
110
|
+
.slice(0, limit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Notified after every state change. Returns an unsubscribe function. */
|
|
114
|
+
export function subscribe(listener: () => void): () => void {
|
|
115
|
+
listeners.add(listener);
|
|
116
|
+
return () => listeners.delete(listener);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The endpoints are rate limited, so results are cached and refreshes are
|
|
121
|
+
* throttled. Failures keep the previous figures on screen.
|
|
122
|
+
*/
|
|
123
|
+
export async function refreshUsage(ctx: any, force = false): Promise<void> {
|
|
124
|
+
if (inFlight) return inFlight;
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
const dueAt = Math.max(nextAllowedFetch, (state.updatedAt ?? 0) + MIN_INTERVAL_MS);
|
|
127
|
+
if (!force && state.updatedAt && now < dueAt) return;
|
|
128
|
+
|
|
129
|
+
inFlight = (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const result = await fetchAll(ctx);
|
|
132
|
+
const rateLimited = result.errors.some((error) => error.includes("429"));
|
|
133
|
+
recordUsageDrops(result.rows);
|
|
134
|
+
|
|
135
|
+
// Per-account merge: groups that failed this cycle keep their last figures.
|
|
136
|
+
const freshGroups = new Set(result.rows.map((row) => row.group));
|
|
137
|
+
const retained = state.rows
|
|
138
|
+
.filter((row) => !row.group.startsWith("Claude pool ×")
|
|
139
|
+
&& !freshGroups.has(row.group)
|
|
140
|
+
&& (!isClaudeAccount(row) || result.groups.includes(row.group)))
|
|
141
|
+
.map((row) => ({ ...row, stale: true }));
|
|
142
|
+
|
|
143
|
+
state.rows = [...result.rows, ...retained];
|
|
144
|
+
state.accounts = result.groups.length;
|
|
145
|
+
state.updatedAt = Date.now(); // poll time only; rows retain their own checkedAt
|
|
146
|
+
state.errors = result.errors;
|
|
147
|
+
if (result.codexPlan !== undefined) state.codexPlan = result.codexPlan;
|
|
148
|
+
nextAllowedFetch = rateLimited ? Date.now() + BACKOFF_MS : 0;
|
|
149
|
+
saveCache();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
state.errors = [`Usage refresh failed: ${error instanceof Error ? error.message : String(error)}`];
|
|
152
|
+
} finally {
|
|
153
|
+
state.loading = false;
|
|
154
|
+
}
|
|
155
|
+
})().finally(() => {
|
|
156
|
+
inFlight = undefined;
|
|
157
|
+
emit();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return inFlight;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Guarantees usable data for a caller that does not own the poll loop.
|
|
165
|
+
* This is what makes `list_models` correct in headless sessions.
|
|
166
|
+
*/
|
|
167
|
+
export async function ensureFresh(ctx: any): Promise<UsageState> {
|
|
168
|
+
const stale = !state.updatedAt || Date.now() - state.updatedAt > REFRESH_MS;
|
|
169
|
+
if (stale) await refreshUsage(ctx);
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Starts the shared interval. Safe to call from multiple domains. */
|
|
174
|
+
export function startPolling(ctx: any): void {
|
|
175
|
+
if (started) return;
|
|
176
|
+
started = true;
|
|
177
|
+
void refreshUsage(ctx);
|
|
178
|
+
timer ??= setInterval(() => void refreshUsage(ctx), REFRESH_MS);
|
|
179
|
+
timer.unref?.();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function stopPolling(): void {
|
|
183
|
+
if (timer) clearInterval(timer);
|
|
184
|
+
timer = undefined;
|
|
185
|
+
started = false;
|
|
186
|
+
}
|
package/src/ui/format.ts
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
/** Presentation helpers with no domain knowledge. */
|
|
2
|
-
|
|
3
|
-
export function formatTokens(count: number): string {
|
|
4
|
-
if (count < 1000) return String(count);
|
|
5
|
-
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
6
|
-
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
7
|
-
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
8
|
-
return `${Math.round(count / 1_000_000)}M`;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function sanitize(text: string): string {
|
|
12
|
-
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Keep long ids readable by trimming the middle rather than the tail. */
|
|
16
|
-
export function fitId(id: string, width: number): string {
|
|
17
|
-
if (id.length <= width) return id.padEnd(width);
|
|
18
|
-
return `${id.slice(0, 12)}…${id.slice(id.length - (width - 13))}`;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function formatShortReset(resetAt?: number): string {
|
|
22
|
-
if (!resetAt) return "";
|
|
23
|
-
const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
|
|
24
|
-
if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
|
|
25
|
-
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
26
|
-
return `${Math.floor(seconds / 86400)}d`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function formatReset(resetAt?: number): string {
|
|
30
|
-
if (!resetAt) return "";
|
|
31
|
-
const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
|
|
32
|
-
if (seconds < 60) return "resets <1m";
|
|
33
|
-
const days = Math.floor(seconds / 86400);
|
|
34
|
-
const hours = Math.floor((seconds % 86400) / 3600);
|
|
35
|
-
const minutes = Math.floor((seconds % 3600) / 60);
|
|
36
|
-
if (days > 0) return `resets ${days}d ${hours}h`;
|
|
37
|
-
if (hours > 0) return `resets ${hours}h ${minutes}m`;
|
|
38
|
-
return `resets ${minutes}m`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") || !!process.env.WT_SESSION;
|
|
42
|
-
|
|
43
|
-
export function hasTruecolor(): boolean {
|
|
44
|
-
return TRUECOLOR;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function hslToAnsi(hue: number, saturation: number, lightness: number): string {
|
|
48
|
-
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
49
|
-
const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
|
|
50
|
-
const match = lightness - chroma / 2;
|
|
51
|
-
const [r, g, b] = hue < 60 ? [chroma, secondary, 0]
|
|
52
|
-
: hue < 120 ? [secondary, chroma, 0]
|
|
53
|
-
: hue < 180 ? [0, chroma, secondary]
|
|
54
|
-
: hue < 240 ? [0, secondary, chroma]
|
|
55
|
-
: hue < 300 ? [secondary, 0, chroma]
|
|
56
|
-
: [chroma, 0, secondary];
|
|
57
|
-
const to255 = (value: number) => Math.round((value + match) * 255);
|
|
58
|
-
return `\u001b[38;2;${to255(r)};${to255(g)};${to255(b)}m`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Smooth red (empty) to green (full) ramp for a 0-100 fullness value. */
|
|
62
|
-
export function levelColor(remaining: number): (text: string) => string {
|
|
63
|
-
const clamped = Math.min(100, Math.max(0, remaining));
|
|
64
|
-
if (!TRUECOLOR) return (text: string) => text;
|
|
65
|
-
const hue = 120 * Math.pow(clamped / 100, 1.35);
|
|
66
|
-
const lightness = clamped <= 12 ? 0.58 : 0.48;
|
|
67
|
-
const escape = hslToAnsi(hue, 0.85, lightness);
|
|
68
|
-
return (text: string) => `${escape}${text}\u001b[39m`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function themeLevel(remaining: number): "error" | "warning" | "success" {
|
|
72
|
-
return remaining <= 10 ? "error" : remaining <= 25 ? "warning" : "success";
|
|
73
|
-
}
|
|
1
|
+
/** Presentation helpers with no domain knowledge. */
|
|
2
|
+
|
|
3
|
+
export function formatTokens(count: number): string {
|
|
4
|
+
if (count < 1000) return String(count);
|
|
5
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
6
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
7
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
8
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function sanitize(text: string): string {
|
|
12
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Keep long ids readable by trimming the middle rather than the tail. */
|
|
16
|
+
export function fitId(id: string, width: number): string {
|
|
17
|
+
if (id.length <= width) return id.padEnd(width);
|
|
18
|
+
return `${id.slice(0, 12)}…${id.slice(id.length - (width - 13))}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatShortReset(resetAt?: number): string {
|
|
22
|
+
if (!resetAt) return "";
|
|
23
|
+
const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
|
|
24
|
+
if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
|
|
25
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
|
26
|
+
return `${Math.floor(seconds / 86400)}d`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatReset(resetAt?: number): string {
|
|
30
|
+
if (!resetAt) return "";
|
|
31
|
+
const seconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
|
|
32
|
+
if (seconds < 60) return "resets <1m";
|
|
33
|
+
const days = Math.floor(seconds / 86400);
|
|
34
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
35
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
36
|
+
if (days > 0) return `resets ${days}d ${hours}h`;
|
|
37
|
+
if (hours > 0) return `resets ${hours}h ${minutes}m`;
|
|
38
|
+
return `resets ${minutes}m`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") || !!process.env.WT_SESSION;
|
|
42
|
+
|
|
43
|
+
export function hasTruecolor(): boolean {
|
|
44
|
+
return TRUECOLOR;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hslToAnsi(hue: number, saturation: number, lightness: number): string {
|
|
48
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
49
|
+
const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
|
|
50
|
+
const match = lightness - chroma / 2;
|
|
51
|
+
const [r, g, b] = hue < 60 ? [chroma, secondary, 0]
|
|
52
|
+
: hue < 120 ? [secondary, chroma, 0]
|
|
53
|
+
: hue < 180 ? [0, chroma, secondary]
|
|
54
|
+
: hue < 240 ? [0, secondary, chroma]
|
|
55
|
+
: hue < 300 ? [secondary, 0, chroma]
|
|
56
|
+
: [chroma, 0, secondary];
|
|
57
|
+
const to255 = (value: number) => Math.round((value + match) * 255);
|
|
58
|
+
return `\u001b[38;2;${to255(r)};${to255(g)};${to255(b)}m`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Smooth red (empty) to green (full) ramp for a 0-100 fullness value. */
|
|
62
|
+
export function levelColor(remaining: number): (text: string) => string {
|
|
63
|
+
const clamped = Math.min(100, Math.max(0, remaining));
|
|
64
|
+
if (!TRUECOLOR) return (text: string) => text;
|
|
65
|
+
const hue = 120 * Math.pow(clamped / 100, 1.35);
|
|
66
|
+
const lightness = clamped <= 12 ? 0.58 : 0.48;
|
|
67
|
+
const escape = hslToAnsi(hue, 0.85, lightness);
|
|
68
|
+
return (text: string) => `${escape}${text}\u001b[39m`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function themeLevel(remaining: number): "error" | "warning" | "success" {
|
|
72
|
+
return remaining <= 10 ? "error" : remaining <= 25 ? "warning" : "success";
|
|
73
|
+
}
|