@jameslovespancakes/pi-plus 1.0.0

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/config/pi-plus.example.json +60 -0
  4. package/config/skills/model-routing/SKILL.md +86 -0
  5. package/images/board_demo.png +0 -0
  6. package/images/pi-plus.svg +10 -0
  7. package/images/pi-plus_demo.png +0 -0
  8. package/images/provider_demo.png +0 -0
  9. package/images/remote_demo.png +0 -0
  10. package/images/usage_demo.png +0 -0
  11. package/package.json +67 -0
  12. package/server/board-server.mjs +641 -0
  13. package/server/package.json +17 -0
  14. package/src/core/accounts/registry.ts +93 -0
  15. package/src/core/anthropic/client-identity.ts +241 -0
  16. package/src/core/anthropic/models.ts +69 -0
  17. package/src/core/anthropic/oauth.ts +208 -0
  18. package/src/core/anthropic/quota.ts +253 -0
  19. package/src/core/anthropic/routing.ts +168 -0
  20. package/src/core/anthropic/store.ts +225 -0
  21. package/src/core/anthropic/vendor/README.md +36 -0
  22. package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
  23. package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
  24. package/src/core/anthropic/xxhash64.ts +33 -0
  25. package/src/core/catalog/quality.ts +314 -0
  26. package/src/core/codex/oauth.ts +129 -0
  27. package/src/core/codex/quota.ts +88 -0
  28. package/src/core/codex/store.ts +97 -0
  29. package/src/core/config.ts +169 -0
  30. package/src/core/env.ts +58 -0
  31. package/src/core/exec/process.ts +146 -0
  32. package/src/core/exec/ssh-config.ts +157 -0
  33. package/src/core/oauth/pkce.ts +88 -0
  34. package/src/core/policy/policy.ts +183 -0
  35. package/src/core/quota/pool.ts +64 -0
  36. package/src/core/quota/usage-source.ts +289 -0
  37. package/src/core/store.ts +43 -0
  38. package/src/domains/agents/board-setup.ts +409 -0
  39. package/src/domains/agents/index.ts +462 -0
  40. package/src/domains/models/catalog-tool.ts +361 -0
  41. package/src/domains/models/index.ts +14 -0
  42. package/src/domains/models/policy-gate.ts +169 -0
  43. package/src/domains/models/provider-picker.ts +208 -0
  44. package/src/domains/remote/config-path.ts +41 -0
  45. package/src/domains/remote/index.ts +866 -0
  46. package/src/domains/remote/setup.ts +425 -0
  47. package/src/domains/setup/index.ts +220 -0
  48. package/src/domains/subscriptions/accounts-picker.ts +178 -0
  49. package/src/domains/subscriptions/accounts.ts +242 -0
  50. package/src/domains/subscriptions/footer.ts +182 -0
  51. package/src/domains/subscriptions/index.ts +42 -0
  52. package/src/domains/subscriptions/provider.ts +219 -0
  53. package/src/domains/subscriptions/providers/anthropic.ts +149 -0
  54. package/src/domains/subscriptions/providers/codex.ts +148 -0
  55. package/src/domains/subscriptions/routing.ts +72 -0
  56. package/src/services/usage-service.ts +186 -0
  57. package/src/ui/format.ts +73 -0
  58. package/src/ui/usage-bars.ts +154 -0
  59. package/src/vendor/anthropic.ts +109 -0
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,154 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { combinedWindow, isClaudeAccount, poolAvailability, scopedLabels, type UsageRow } from "../core/quota/pool.ts";
3
+ import type { UsageState } from "../services/usage-service.ts";
4
+ import { formatReset, formatShortReset, hasTruecolor, levelColor, themeLevel } from "./format.ts";
5
+
6
+ /**
7
+ * Renders the Claude/Codex quota bars. Takes state as an argument rather than
8
+ * importing the service, so the renderer stays a pure function of its input.
9
+ */
10
+
11
+ type Cell = { label: string; remaining?: number; resetAt?: number; partial?: boolean };
12
+
13
+ function pooled(state: UsageState, label: string): Cell | undefined {
14
+ return combinedWindow(state.rows, label, state.accounts, Date.now(), true);
15
+ }
16
+
17
+ function codexCell(state: UsageState, display: string, match: (label: string) => boolean): Cell | undefined {
18
+ const row = state.rows.find((candidate) => candidate.group === "Codex" && match(candidate.label));
19
+ return row ? { label: display, remaining: row.remaining, resetAt: row.resetAt } : { label: display };
20
+ }
21
+
22
+ /**
23
+ * Turns a scoped limit id into something that fits the label column.
24
+ *
25
+ * Anthropic names these with an internal id, e.g.
26
+ * `7d claude-weekly-scoped-fable`. Rendered raw it was truncated to the 6
27
+ * column label width and came out as "claude", which named neither the window
28
+ * nor the model. The trailing segment is the model family, so that is what is
29
+ * shown.
30
+ */
31
+ function scopedDisplayName(label: string): string {
32
+ const family = label.replace(/^7d\s+/, "").split("-").pop() ?? label;
33
+ return family.charAt(0).toUpperCase() + family.slice(1);
34
+ }
35
+
36
+ function buildColumns(state: UsageState, modelId?: string): { claude: Cell[]; codex: Cell[] } {
37
+ // Three fixed tiers, so the block keeps its shape whether or not a scoped
38
+ // limit is currently reported.
39
+ const scoped = scopedLabels(state.rows, modelId)[0];
40
+ const scopedCell = scoped
41
+ ? { ...(pooled(state, scoped) ?? { label: scoped }), label: scopedDisplayName(scoped) }
42
+ : { label: "Fable" };
43
+
44
+ const claude: Cell[] = [
45
+ { ...(pooled(state, "5h") ?? { label: "5h" }), label: "5h" },
46
+ { ...(pooled(state, "7d") ?? { label: "7d" }), label: "weekly" },
47
+ scopedCell,
48
+ ];
49
+
50
+ // Codex reports only the two windows; it has no scoped equivalent.
51
+ const codex: Cell[] = [
52
+ codexCell(state, "5h", (label) => label === "5h") ?? { label: "5h" },
53
+ codexCell(state, "weekly", (label) => label === "weekly") ?? { label: "weekly" },
54
+ ];
55
+ return { claude, codex };
56
+ }
57
+
58
+ /** `Work 61% · Personal 88%`. Empty when there is nothing extra to say. */
59
+ function renderAccountSummary(state: UsageState, cellWidth: number): string | undefined {
60
+ const stamps = state.lastUsedAt ?? {};
61
+ const groups = [...new Set(state.rows.filter(isClaudeAccount).map((row) => row.group))];
62
+ if (groups.length < 2) return undefined;
63
+
64
+ const ordered = groups.sort((a, b) => (stamps[b] ?? 0) - (stamps[a] ?? 0) || a.localeCompare(b));
65
+ const shown = ordered.slice(0, 2);
66
+
67
+ const parts = shown.map((group) => {
68
+ const row = state.rows.find((candidate) => candidate.group === group && candidate.label === "5h");
69
+ const name = group.replace(/^Claude /, "");
70
+ if (!row) return `${name} -`;
71
+ return `${name} ${Math.round(row.remaining)}%${row.stale ? "*" : ""}`;
72
+ });
73
+
74
+ const hidden = ordered.length - shown.length;
75
+ const text = parts.join(" · ") + (hidden > 0 ? ` +${hidden}` : "");
76
+ return text.length > cellWidth * 2 ? text.slice(0, cellWidth * 2) : text;
77
+ }
78
+
79
+ function renderCell(theme: any, cell: Cell, labelWidth: number, cellWidth: number): string {
80
+ const label = cell.label.slice(0, labelWidth).padEnd(labelWidth);
81
+ const reset = formatShortReset(cell.resetAt);
82
+ const resetWidth = 4;
83
+ const barWidth = Math.max(4, cellWidth - labelWidth - 6 - resetWidth - 3);
84
+
85
+ if (cell.remaining === undefined) {
86
+ return `${theme.fg("muted", label)} ${theme.fg("dim", "·".repeat(barWidth))} ${theme.fg("dim", " n/a")}${" ".repeat(resetWidth + 1)}`;
87
+ }
88
+
89
+ const filled = Math.round((cell.remaining / 100) * barWidth);
90
+ const percentText = `${cell.partial ? "~" : ""}${Math.round(cell.remaining)}%`.padStart(5);
91
+ let bar: string;
92
+ let percent: string;
93
+
94
+ if (hasTruecolor()) {
95
+ // One smooth hue per bar: green when full, amber mid-way, red as it empties.
96
+ const paint = levelColor(cell.remaining);
97
+ bar = paint("█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled));
98
+ percent = paint(percentText);
99
+ } else {
100
+ const color = themeLevel(cell.remaining);
101
+ bar = theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(barWidth - filled));
102
+ percent = theme.fg(color, percentText);
103
+ }
104
+
105
+ return `${theme.fg("muted", label)} ${bar} ${percent} ${theme.fg("dim", reset.padEnd(resetWidth))}`;
106
+ }
107
+
108
+ export function renderUsageLines(state: UsageState, theme: any, width: number, modelId?: string): string[] {
109
+ if (state.loading) return [theme.fg("dim", " usage: loading…")];
110
+ if (state.rows.length === 0) {
111
+ if (state.errors.length > 0) return state.errors.map((error) => theme.fg("warning", ` ${error}`));
112
+ return [theme.fg("dim", " usage: unavailable")];
113
+ }
114
+
115
+ const gap = 3;
116
+ const cellWidth = Math.max(22, Math.floor((width - 2 - gap) / 2));
117
+ const labelWidth = 6;
118
+ const { claude, codex } = buildColumns(state, modelId);
119
+ const availability = poolAvailability(state.rows, state.accounts, modelId);
120
+ const status = availability.ready
121
+ ? `${availability.ready}/${availability.total} ready`
122
+ : availability.unknown ? "unknown/stale" : "exhausted";
123
+ const partial = claude.some((cell) => cell.partial);
124
+ const claudeTitle = `Claude Σ${state.accounts} · ${status}${partial ? " · partial" : ""}`;
125
+ const codexTitle = state.codexPlan ? `Codex · ${state.codexPlan}` : "Codex";
126
+ const lines = [` ${theme.fg("accent", claudeTitle.padEnd(cellWidth))}${" ".repeat(gap)}${theme.fg("accent", codexTitle)}`];
127
+
128
+ for (let index = 0; index < Math.max(claude.length, codex.length); index += 1) {
129
+ const left = claude[index] ? renderCell(theme, claude[index], labelWidth, cellWidth) : " ".repeat(cellWidth);
130
+ const right = codex[index] ? renderCell(theme, codex[index], labelWidth, cellWidth) : "";
131
+ lines.push(` ${left}${" ".repeat(gap)}${right}`);
132
+ }
133
+
134
+ // Per-account detail. With more than two accounts only the two most recently
135
+ // used are shown, so the footer stays two lines regardless of pool size.
136
+ const accountLine = renderAccountSummary(state, cellWidth);
137
+ if (accountLine) lines.push(` ${theme.fg("dim", accountLine)}`);
138
+
139
+ for (const error of state.errors) lines.push(theme.fg("warning", ` ${error}`));
140
+ return lines.map((line) => truncateToWidth(line, width, ""));
141
+ }
142
+
143
+ export function usageSummaryText(state: UsageState): string {
144
+ const combined = ["5h", "7d", ...scopedLabels(state.rows)].map((label) => {
145
+ const pool = combinedWindow(state.rows, label, state.accounts, Date.now(), true);
146
+ return `Claude combined ${label}: ${pool
147
+ ? `${pool.partial ? "~" : ""}${Math.round(pool.remaining)}% left${pool.partial ? " (partial: reporting accounts only)" : ""} ${formatReset(pool.resetAt)}`
148
+ : "unknown/stale"}`;
149
+ });
150
+ const summary = [...combined, ...state.rows.map(
151
+ (row: UsageRow) => `${row.group} ${row.label}: ${Math.round(row.remaining)}% left ${formatReset(row.resetAt)}${row.stale ? " (stale)" : ""}`.trim(),
152
+ )];
153
+ return [...summary, ...state.errors].join("\n") || "No usage data";
154
+ }
@@ -0,0 +1,109 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ /**
6
+ * The single import point for `@cortexkit/anthropic-auth-core`.
7
+ *
8
+ * Nothing else in this repo may reach into the pi-managed npm tree. The old
9
+ * code hardcoded `../npm/node_modules/@cortexkit/...` relative to the
10
+ * extension file, which broke whenever an extension moved directory depth.
11
+ * Resolving from the agent dir at runtime makes placement irrelevant.
12
+ */
13
+
14
+ function agentDir(): string {
15
+ return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function coreEntry(): string {
19
+ return pathToFileURL(
20
+ join(agentDir(), "npm", "node_modules", "@cortexkit", "anthropic-auth-core", "dist", "index.js"),
21
+ ).href;
22
+ }
23
+
24
+ export interface AnthropicAccount {
25
+ id: string;
26
+ label?: string;
27
+ type: string;
28
+ enabled?: boolean;
29
+ access?: string;
30
+ refresh?: string;
31
+ expires?: number;
32
+ addedAt?: number;
33
+ lastRefreshedAt?: number;
34
+ authLineageId?: string;
35
+ }
36
+
37
+ export interface AccountStorage {
38
+ accounts: AnthropicAccount[];
39
+ }
40
+
41
+ interface AnthropicCore {
42
+ addAccountPersistent(account: AnthropicAccount, path: string): Promise<void>;
43
+ loadAccounts(path: string): Promise<AccountStorage | undefined>;
44
+ authorize(mode: string): Promise<{ url: string; verifier: string; redirectUri: string; state: string }>;
45
+ exchange(
46
+ callback: string,
47
+ verifier: string,
48
+ redirectUri: string,
49
+ state: string,
50
+ ): Promise<{ type: string; access: string; refresh: string; expires: number }>;
51
+ refreshClaudeOAuthToken(options: {
52
+ refreshToken: string;
53
+ maxRetries?: number;
54
+ fetchImpl?: typeof fetch;
55
+ }): Promise<{ access: string; refresh: string; expires: number }>;
56
+ getRoutingMode(storage: unknown): string;
57
+ setRoutingMode(mode: string, path: string): Promise<unknown>;
58
+ }
59
+
60
+ let cached: Promise<AnthropicCore> | undefined;
61
+
62
+ /** Lazily loads and memoizes the vendor core module. */
63
+ export function core(): Promise<AnthropicCore> {
64
+ cached ??= import(coreEntry()).then((module) => module as unknown as AnthropicCore).catch((error) => {
65
+ cached = undefined;
66
+ throw new Error(
67
+ `@cortexkit/anthropic-auth-core is unavailable (${error instanceof Error ? error.message : String(error)}). `
68
+ + "Reinstall with: pi package add @cortexkit/pi-anthropic-auth",
69
+ );
70
+ });
71
+ return cached;
72
+ }
73
+
74
+ /** Where the multi-account OAuth store lives. */
75
+ export function accountStoragePath(): string {
76
+ return process.env.PI_ANTHROPIC_AUTH_FILE ?? join(agentDir(), "anthropic-auth.json");
77
+ }
78
+
79
+ export async function loadAccounts(): Promise<AccountStorage | undefined> {
80
+ return (await core()).loadAccounts(accountStoragePath());
81
+ }
82
+
83
+ export async function saveAccount(account: AnthropicAccount): Promise<void> {
84
+ return (await core()).addAccountPersistent(account, accountStoragePath());
85
+ }
86
+
87
+ export async function refreshToken(
88
+ refreshTokenValue: string,
89
+ options: { maxRetries?: number; timeoutMs?: number } = {},
90
+ ): Promise<{ access: string; refresh: string; expires: number }> {
91
+ const { refreshClaudeOAuthToken } = await core();
92
+ return refreshClaudeOAuthToken({
93
+ refreshToken: refreshTokenValue,
94
+ maxRetries: options.maxRetries,
95
+ fetchImpl: options.timeoutMs
96
+ ? (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(options.timeoutMs!) })
97
+ : undefined,
98
+ });
99
+ }
100
+
101
+ export async function routingMode(): Promise<string> {
102
+ const { getRoutingMode } = await core();
103
+ return getRoutingMode(await loadAccounts());
104
+ }
105
+
106
+ export async function setRoutingMode(mode: "sticky-balanced" | "main-first"): Promise<string> {
107
+ const { setRoutingMode: apply, getRoutingMode } = await core();
108
+ return getRoutingMode(await apply(mode, accountStoragePath()));
109
+ }