@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,178 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
+ import type { AccountProvider, ManagedAccount } from "../../core/accounts/registry.ts";
4
+
5
+ /**
6
+ * The `/accounts` list.
7
+ *
8
+ * Mirrors the provider picker: an inline SettingsList inside pi's own rule
9
+ * chrome, toggled in place so nothing redraws the screen. Rows are grouped by
10
+ * provider, because an account label alone ("Personal") does not say which
11
+ * subscription it belongs to.
12
+ */
13
+
14
+ export type AccountState = "enabled" | "disabled";
15
+
16
+ export const ACCOUNT_STATE_TEXT: Record<AccountState, string> = {
17
+ enabled: "Enabled",
18
+ disabled: "Disabled",
19
+ };
20
+
21
+ /** Enabled reads as a full quota bar, disabled as an empty one. */
22
+ const STATE_LEVEL: Record<AccountState, number> = { enabled: 100, disabled: 0 };
23
+ const STATE_THEME: Record<AccountState, string> = { enabled: "success", disabled: "muted" };
24
+
25
+ export function colourAccountState(theme: any, state: AccountState, text: string = ACCOUNT_STATE_TEXT[state]): string {
26
+ if (hasTruecolor()) return levelColor(STATE_LEVEL[state])(text);
27
+ return theme.fg(STATE_THEME[state], text);
28
+ }
29
+
30
+ export interface AccountRow {
31
+ id: string;
32
+ providerId: string;
33
+ providerLabel: string;
34
+ label: string;
35
+ state: AccountState;
36
+ detail?: string;
37
+ }
38
+
39
+ /** Flattens every provider's accounts into one ordered list. */
40
+ export async function accountRows(providers: AccountProvider[]): Promise<AccountRow[]> {
41
+ const rows: AccountRow[] = [];
42
+ for (const provider of providers) {
43
+ let accounts: ManagedAccount[] = [];
44
+ try {
45
+ accounts = await provider.list();
46
+ } catch {
47
+ continue; // A provider that cannot enumerate is simply not shown.
48
+ }
49
+ for (const account of accounts) {
50
+ rows.push({
51
+ id: `${provider.id}:${account.id}`,
52
+ providerId: provider.id,
53
+ providerLabel: provider.label,
54
+ label: account.label,
55
+ state: account.enabled ? "enabled" : "disabled",
56
+ detail: account.primary ? "primary" : undefined,
57
+ });
58
+ }
59
+ }
60
+ return rows;
61
+ }
62
+
63
+ function labelFor(theme: any, row: AccountRow): string {
64
+ const dot = colourAccountState(theme, row.state, "●");
65
+ const name = row.state === "disabled" ? theme.fg("muted", row.label) : row.label;
66
+ return `${dot} ${theme.fg("dim", row.providerLabel)} ${name}`;
67
+ }
68
+
69
+ function framed(theme: any, list: any, title: string): Component {
70
+ const rule = (w: number) => theme.fg("accent", "─".repeat(Math.max(1, w)));
71
+ return {
72
+ invalidate: () => list.invalidate?.(),
73
+ handleInput: (data: string) => list.handleInput(data),
74
+ handleMouse: (event: any) => list.handleMouse?.(event),
75
+ render(width: number): string[] {
76
+ const inner = Math.max(1, width);
77
+ return [rule(inner), ` ${theme.fg("accent", theme.bold(title))}`, ...list.render(inner), rule(inner)];
78
+ },
79
+ } as Component;
80
+ }
81
+
82
+ /**
83
+ * What closing the picker asked for.
84
+ *
85
+ * The wizards run AFTER the picker closes rather than inside it, so they can
86
+ * use pi's ordinary select/input prompts instead of being reimplemented as
87
+ * nested TUI components.
88
+ */
89
+ export type PickerAction = { kind: "add" } | { kind: "rename" } | undefined;
90
+
91
+ /** Action row ids are namespaced so they cannot collide with an account id. */
92
+ const ADD_ID = "__action_add";
93
+ const RENAME_ID = "__action_rename";
94
+
95
+ export interface AccountPickerDeps {
96
+ rows: () => Promise<AccountRow[]>;
97
+ /** Applies a toggle and returns the resulting state. Synchronous so the
98
+ * label, dot and value all change in one render. */
99
+ toggle: (providerId: string, accountId: string) => AccountState;
100
+ }
101
+
102
+ export async function openAccountsPicker(ctx: any, deps: AccountPickerDeps): Promise<PickerAction> {
103
+ const rows = await deps.rows();
104
+
105
+ const { SettingsList } = await import("@earendil-works/pi-tui");
106
+
107
+ return await ctx.ui.custom((_tui: any, theme: any, _keys: any, done: (value?: unknown) => void) => {
108
+ const toggleValues = [
109
+ colourAccountState(theme, "enabled"),
110
+ colourAccountState(theme, "disabled"),
111
+ ];
112
+
113
+ const items: any[] = rows.map((row) => ({
114
+ id: row.id,
115
+ label: labelFor(theme, row),
116
+ values: [...toggleValues],
117
+ currentValue: colourAccountState(theme, row.state),
118
+ description: row.detail,
119
+ }));
120
+
121
+ // Actions live as rows rather than hidden keystrokes, so they are
122
+ // discoverable. A single-entry `values` makes Enter fire onChange without
123
+ // the row appearing to cycle through anything.
124
+ items.push({
125
+ id: ADD_ID,
126
+ label: theme.fg("accent", "+ Add account"),
127
+ values: [""],
128
+ currentValue: "",
129
+ description: "Authorize another subscription",
130
+ });
131
+ if (rows.length > 0) {
132
+ items.push({
133
+ id: RENAME_ID,
134
+ label: theme.fg("accent", "✎ Rename account"),
135
+ values: [""],
136
+ currentValue: "",
137
+ description: "Change an account's display name",
138
+ });
139
+ }
140
+
141
+ let list: any;
142
+
143
+ const onChange = (id: string) => {
144
+ if (id === ADD_ID) { done({ kind: "add" }); return; }
145
+ if (id === RENAME_ID) { done({ kind: "rename" }); return; }
146
+
147
+ const row = rows.find((r) => r.id === id);
148
+ const item = items.find((i) => i.id === id);
149
+ if (!row || !item) return;
150
+ try {
151
+ const next = deps.toggle(row.providerId, row.id.slice(row.providerId.length + 1));
152
+ row.state = next;
153
+ item.label = labelFor(theme, row);
154
+ list?.updateValue(id, colourAccountState(theme, next));
155
+ list?.invalidate?.();
156
+ } catch (error) {
157
+ list?.updateValue(id, colourAccountState(theme, row.state));
158
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
159
+ }
160
+ };
161
+
162
+ list = new SettingsList(
163
+ items,
164
+ Math.min(items.length, 12),
165
+ {
166
+ label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
167
+ value: (text: string) => text,
168
+ description: (text: string) => theme.fg("dim", text),
169
+ cursor: theme.fg("accent", "›"),
170
+ hint: (text: string) => theme.fg("dim", text),
171
+ },
172
+ onChange,
173
+ () => done(undefined),
174
+ );
175
+
176
+ return framed(theme, list, "Accounts") as Component & { dispose?(): void };
177
+ });
178
+ }
@@ -0,0 +1,242 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { accountRows, openAccountsPicker, type AccountState } from "./accounts-picker.ts";
3
+ import {
4
+ accountProvider,
5
+ accountProviders,
6
+ type AccountContext,
7
+ type AccountProvider,
8
+ type ManagedAccount,
9
+ } from "../../core/accounts/registry.ts";
10
+
11
+ /**
12
+ * `/accounts` is provider-agnostic subscription account management.
13
+ *
14
+ * /accounts every provider's accounts, toggled enabled/disabled
15
+ * /accounts add pick a provider, name the account, authorize
16
+ * /accounts reauth reauthorize an existing account
17
+ *
18
+ * No provider-specific logic lives here; adapters are registered in
19
+ * core/accounts/registry.ts, so a new provider is an adapter rather than a
20
+ * new command.
21
+ */
22
+
23
+ async function openBrowser(pi: ExtensionAPI, url: string): Promise<void> {
24
+ if (process.platform === "win32") {
25
+ await pi.exec("rundll32.exe", ["url.dll,FileProtocolHandler", url]);
26
+ return;
27
+ }
28
+ if (process.platform === "darwin") {
29
+ await pi.exec("open", [url]);
30
+ return;
31
+ }
32
+ await pi.exec("xdg-open", [url]);
33
+ }
34
+
35
+ function bridge(pi: ExtensionAPI, ctx: any): AccountContext {
36
+ return {
37
+ hasUI: ctx.hasUI,
38
+ ui: {
39
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
40
+ confirm: (title, message) => ctx.ui.confirm(title, message),
41
+ notify: (message, type) => ctx.ui.notify(message, type ?? "info"),
42
+ },
43
+ openBrowser: (url) => openBrowser(pi, url),
44
+ };
45
+ }
46
+
47
+ function describe(account: ManagedAccount): string {
48
+ const state = !account.enabled
49
+ ? "disabled"
50
+ : account.expiresAt !== undefined && account.expiresAt < Date.now() ? "expired" : "active";
51
+ return `${account.label.padEnd(20)} ${state}`;
52
+ }
53
+
54
+ async function listAll(ctx: any): Promise<void> {
55
+ const providers = accountProviders();
56
+ if (providers.length === 0) {
57
+ ctx.ui.notify("No account providers are registered.", "warning");
58
+ return;
59
+ }
60
+
61
+ const blocks: string[] = [];
62
+ for (const provider of providers) {
63
+ try {
64
+ const accounts = await provider.list();
65
+ const routing = provider.routing ? ` · routing: ${await provider.routing.get()}` : "";
66
+ blocks.push(
67
+ `${provider.label} (${provider.id})${routing}`,
68
+ ...(accounts.length > 0
69
+ ? accounts.map((account) => ` ${describe(account)}`)
70
+ : [` no additional accounts, add one with /account ${provider.id} add`]),
71
+ );
72
+ } catch (error) {
73
+ blocks.push(`${provider.label} (${provider.id}): ${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ }
76
+ ctx.ui.notify(blocks.join("\n"), "info");
77
+ }
78
+
79
+
80
+
81
+ async function pickAccount(ctx: any, provider: AccountProvider): Promise<string | undefined> {
82
+ const accounts = await provider.list();
83
+ if (accounts.length === 0) {
84
+ ctx.ui.notify(`No ${provider.label} accounts yet. Add one with /account ${provider.id} add.`, "info");
85
+ return undefined;
86
+ }
87
+ if (!ctx.hasUI) {
88
+ ctx.ui.notify(`Specify an account: /account ${provider.id} reauth <label>`, "warning");
89
+ return undefined;
90
+ }
91
+
92
+ const labels = accounts.map(describe);
93
+ const choice = await ctx.ui.select(`Reauthorize which ${provider.label} account?`, labels);
94
+ if (!choice) return undefined;
95
+ return accounts[labels.indexOf(choice)]?.id;
96
+ }
97
+
98
+ export function registerAccountCommands(pi: ExtensionAPI): void {
99
+ /** Toggles one account and returns the state it ended in. */
100
+ const toggle = (providerId: string, accountId: string): AccountState => {
101
+ const provider = accountProvider(providerId);
102
+ if (!provider?.setEnabled) throw new Error(`${providerId} accounts cannot be disabled.`);
103
+ const next = pendingState.get(`${providerId}:${accountId}`) === "enabled" ? "disabled" : "enabled";
104
+ pendingState.set(`${providerId}:${accountId}`, next);
105
+ // Adapters persist asynchronously; the picker needs the answer now, so the
106
+ // write is fired off and failures surface as a notification.
107
+ void provider.setEnabled(accountId, next === "enabled").catch(() => {});
108
+ return next;
109
+ };
110
+
111
+ /** Mirror of on-disk state, so the synchronous toggle can answer instantly. */
112
+ const pendingState = new Map<string, AccountState>();
113
+
114
+ const rows = async () => {
115
+ const list = await accountRows(accountProviders());
116
+ pendingState.clear();
117
+ for (const row of list) pendingState.set(row.id, row.state);
118
+ return list;
119
+ };
120
+
121
+ /** Adds an account: choose provider, name it, authorize. */
122
+ async function addAccount(ctx: any, providerId?: string): Promise<void> {
123
+ if (!ctx.hasUI) {
124
+ ctx.ui.notify("Adding an account requires interactive Pi mode.", "error");
125
+ return;
126
+ }
127
+
128
+ const providers = accountProviders();
129
+ if (providers.length === 0) {
130
+ ctx.ui.notify("No subscription providers are registered.", "error");
131
+ return;
132
+ }
133
+
134
+ let provider = providerId ? accountProvider(providerId) : undefined;
135
+ if (!provider) {
136
+ if (providers.length === 1) {
137
+ provider = providers[0];
138
+ } else {
139
+ const labels = providers.map((p) => `${p.label} (${p.id})`);
140
+ const picked = await ctx.ui.select("Add an account for which subscription?", labels);
141
+ if (!picked) return;
142
+ provider = providers[labels.indexOf(picked)];
143
+ }
144
+ }
145
+ if (!provider) return;
146
+
147
+ const label = await ctx.ui.input(`${provider.label} account name`, "Work, Personal, etc.");
148
+ if (!label?.trim()) return;
149
+
150
+ try {
151
+ const added = await provider.add(bridge(pi, ctx), label.trim());
152
+ if (added) ctx.ui.notify(`${provider.label} account “${added}” added.`, "info");
153
+ } catch (error) {
154
+ ctx.ui.notify(`Could not add account: ${error instanceof Error ? error.message : String(error)}`, "error");
155
+ }
156
+ }
157
+
158
+ /** Rename wizard: pick an account, type a new name. */
159
+ async function renameAccount(ctx: any): Promise<void> {
160
+ if (!ctx.hasUI) {
161
+ ctx.ui.notify("Renaming an account requires interactive Pi mode.", "error");
162
+ return;
163
+ }
164
+ const list = await accountRows(accountProviders());
165
+ const renameable = list.filter((row) => accountProvider(row.providerId)?.rename);
166
+ if (renameable.length === 0) {
167
+ ctx.ui.notify("No accounts can be renamed.", "info");
168
+ return;
169
+ }
170
+
171
+ const labels = renameable.map((row) => `${row.providerLabel} ${row.label}`);
172
+ const picked = await ctx.ui.select("Rename which account?", labels);
173
+ if (!picked) return;
174
+ const row = renameable[labels.indexOf(picked)];
175
+ if (!row) return;
176
+
177
+ const next = await ctx.ui.input("New name", row.label);
178
+ if (!next?.trim() || next.trim() === row.label) return;
179
+
180
+ const provider = accountProvider(row.providerId);
181
+ try {
182
+ // Row ids are "<providerId>:<accountId>"; strip the prefix.
183
+ await provider!.rename!(row.id.slice(row.providerId.length + 1), next.trim());
184
+ ctx.ui.notify(`Renamed to “${next.trim()}”.`, "info");
185
+ } catch (error) {
186
+ ctx.ui.notify(`Could not rename: ${error instanceof Error ? error.message : String(error)}`, "error");
187
+ }
188
+ }
189
+
190
+ pi.registerCommand("accounts", {
191
+ description: "Subscription accounts (/accounts [add|rename|reauth])",
192
+ getArgumentCompletions: (prefix) => {
193
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
194
+ if (parts.length <= 1) {
195
+ return ["add", "rename", "reauth"]
196
+ .filter((option) => option.startsWith(parts[0] ?? ""))
197
+ .map((option) => ({ value: option, label: option }));
198
+ }
199
+ return accountProviders()
200
+ .map((p) => p.id)
201
+ .filter((id) => id.startsWith(parts[1] ?? ""))
202
+ .map((id) => ({ value: `${parts[0]} ${id}`, label: id }));
203
+ },
204
+ handler: async (args, ctx) => {
205
+ const [action, providerId] = args.trim().split(/\s+/).filter(Boolean);
206
+
207
+ if (action === "add") return addAccount(ctx, providerId);
208
+ if (action === "rename") return renameAccount(ctx);
209
+
210
+ if (action === "reauth") {
211
+ const provider = providerId ? accountProvider(providerId) : accountProviders()[0];
212
+ if (!provider) { ctx.ui.notify("No subscription providers are registered.", "error"); return; }
213
+ const target = await pickAccount(ctx, provider);
214
+ if (!target) return;
215
+ try {
216
+ const done = await provider.reauth(bridge(pi, ctx), target);
217
+ if (done) ctx.ui.notify(`${provider.label} account “${done}” reauthorized.`, "info");
218
+ } catch (error) {
219
+ ctx.ui.notify(`Could not reauthorize: ${error instanceof Error ? error.message : String(error)}`, "error");
220
+ }
221
+ return;
222
+ }
223
+
224
+ if (action) {
225
+ ctx.ui.notify(`Unknown action “${action}”. Use: /accounts [add|rename|reauth]`, "warning");
226
+ return;
227
+ }
228
+
229
+ // Bare /accounts: the picker interactively, plain text headless.
230
+ if (!ctx.hasUI) return listAll(ctx);
231
+
232
+ // The picker closes to run a wizard, then reopens so the result is
233
+ // visible immediately. Bounded so a misbehaving action cannot spin.
234
+ for (let step = 0; step < 24; step++) {
235
+ const requested = await openAccountsPicker(ctx, { rows, toggle });
236
+ if (!requested) return;
237
+ if (requested.kind === "add") await addAccount(ctx);
238
+ else if (requested.kind === "rename") await renameAccount(ctx);
239
+ }
240
+ },
241
+ });
242
+ }
@@ -0,0 +1,182 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { refreshUsage, startPolling, stopPolling, subscribe, usageState } from "../../services/usage-service.ts";
4
+ import { renderUsageLines, usageSummaryText } from "../../ui/usage-bars.ts";
5
+ import { formatTokens, sanitize } from "../../ui/format.ts";
6
+
7
+ /**
8
+ * Single-line session footer (no working-directory line) with the subscription
9
+ * bars rendered directly underneath it.
10
+ *
11
+ * The footer no longer owns the poll loop; it subscribes to usage-service and
12
+ * re-renders on change. That is what lets non-UI consumers get fresh data.
13
+ */
14
+
15
+ const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "kimi-coding"]);
16
+
17
+ export function registerFooter(pi: ExtensionAPI): void {
18
+ let showUsage = true;
19
+ let requestRender: (() => void) | undefined;
20
+ let unsubscribe: (() => void) | undefined;
21
+
22
+ const apply = (ctx: any) => {
23
+ if (!ctx.hasUI) return;
24
+ if (!showUsage) {
25
+ ctx.ui.setFooter(undefined);
26
+ return;
27
+ }
28
+
29
+ ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
30
+ requestRender = () => tui.requestRender();
31
+ return {
32
+ invalidate() {},
33
+ render(rawWidth: number): string[] {
34
+ const width = Math.max(1, Math.floor(Number(rawWidth) || 0));
35
+ const lines: string[] = [];
36
+
37
+ let input = 0;
38
+ let output = 0;
39
+ let cacheRead = 0;
40
+ let cacheWrite = 0;
41
+ let cost = 0;
42
+ let latestHitRate: number | undefined;
43
+
44
+ for (const entry of ctx.sessionManager.getEntries()) {
45
+ const usage = entry.type === "message"
46
+ ? (entry.message.role === "assistant" || entry.message.role === "toolResult" ? entry.message.usage : undefined)
47
+ : (entry.type === "branch_summary" || entry.type === "compaction" ? entry.usage : undefined);
48
+ if (!usage) continue;
49
+ input += usage.input ?? 0;
50
+ output += usage.output ?? 0;
51
+ cacheRead += usage.cacheRead ?? 0;
52
+ cacheWrite += usage.cacheWrite ?? 0;
53
+ cost += usage.cost?.total ?? 0;
54
+ if (entry.type === "message" && entry.message.role === "assistant") {
55
+ const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
56
+ latestHitRate = prompt > 0 ? ((usage.cacheRead ?? 0) / prompt) * 100 : undefined;
57
+ }
58
+ }
59
+
60
+ const parts: string[] = [];
61
+ if (input) parts.push(`↑${formatTokens(input)}`);
62
+ if (output) parts.push(`↓${formatTokens(output)}`);
63
+ if (cacheRead) parts.push(`R${formatTokens(cacheRead)}`);
64
+ if (cacheWrite) parts.push(`W${formatTokens(cacheWrite)}`);
65
+ if ((cacheRead || cacheWrite) && latestHitRate !== undefined) parts.push(`CH${latestHitRate.toFixed(1)}%`);
66
+
67
+ const model = ctx.model;
68
+ const subscription = model ? SUBSCRIPTION_PROVIDERS.has(model.provider) : false;
69
+ if (cost || subscription) parts.push(`$${cost.toFixed(3)}${subscription ? " (sub)" : ""}`);
70
+
71
+ const contextUsage = ctx.getContextUsage();
72
+ const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
73
+ const percentValue = contextUsage?.percent ?? 0;
74
+ const percentText = contextUsage?.percent != null ? `${percentValue.toFixed(1)}%` : "?";
75
+ const contextText = `${percentText}/${formatTokens(contextWindow)}`;
76
+ parts.push(percentValue > 90
77
+ ? theme.fg("error", contextText)
78
+ : percentValue > 70 ? theme.fg("warning", contextText) : contextText);
79
+
80
+ const branch = footerData.getGitBranch?.();
81
+ if (branch) parts.push(`⎇ ${branch}`);
82
+
83
+ let left = parts.join(" ");
84
+ if (visibleWidth(left) > width) left = truncateToWidth(left, width, "...");
85
+ let leftWidth = visibleWidth(left);
86
+
87
+ let right = model?.id ?? "no-model";
88
+ if (model?.reasoning) {
89
+ const level = ctx.thinkingLevel || "off";
90
+ right = level === "off" ? `${right} • thinking off` : `${right} • ${level}`;
91
+ }
92
+ if (footerData.getAvailableProviderCount?.() > 1 && model) {
93
+ const withProvider = `(${model.provider}) ${right}`;
94
+ if (leftWidth + 2 + visibleWidth(withProvider) <= width) right = withProvider;
95
+ }
96
+
97
+ // Keep the composed line within `width`: shrink right, then left.
98
+ let rightWidth = visibleWidth(right);
99
+ if (rightWidth > width) {
100
+ right = truncateToWidth(right, width, "...");
101
+ rightWidth = visibleWidth(right);
102
+ }
103
+ if (leftWidth + 2 + rightWidth > width) {
104
+ const leftBudget = Math.max(0, width - rightWidth - 2);
105
+ left = leftBudget > 0 ? truncateToWidth(left, leftBudget, "...") : "";
106
+ leftWidth = visibleWidth(left);
107
+ }
108
+ const padding = " ".repeat(Math.max(0, width - leftWidth - rightWidth));
109
+ lines.push(truncateToWidth(theme.fg("dim", left) + theme.fg("dim", padding + right), width, ""));
110
+
111
+ const statuses = footerData.getExtensionStatuses?.() as Map<string, string> | undefined;
112
+ if (statuses && statuses.size > 0) {
113
+ const statusLine = Array.from(statuses.entries())
114
+ .sort(([a], [b]) => a.localeCompare(b))
115
+ .map(([, text]) => sanitize(text))
116
+ .join(" ");
117
+ lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
118
+ }
119
+
120
+ if (showUsage) {
121
+ lines.push(...renderUsageLines(
122
+ usageState(),
123
+ theme,
124
+ width,
125
+ model?.provider === "anthropic" ? model.id : undefined,
126
+ ));
127
+ }
128
+
129
+ // Final safety net: never emit a line wider than the terminal.
130
+ return lines.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width, "") : line));
131
+ },
132
+ };
133
+ });
134
+ };
135
+
136
+ pi.on("session_start", async (_event, ctx) => {
137
+ apply(ctx);
138
+ unsubscribe ??= subscribe(() => requestRender?.());
139
+ // Polling is started even without a UI so headless consumers stay current.
140
+ startPolling(ctx);
141
+ });
142
+
143
+ pi.on("model_select", async (_event, ctx) => {
144
+ apply(ctx);
145
+ requestRender?.();
146
+ });
147
+
148
+ pi.on("agent_settled", async (_event, ctx) => {
149
+ // Status polling is cosmetic: never hold up agent completion on auth/network.
150
+ if (ctx.hasUI && showUsage) void refreshUsage(ctx);
151
+ });
152
+
153
+ pi.on("session_shutdown", async () => {
154
+ unsubscribe?.();
155
+ unsubscribe = undefined;
156
+ stopPolling();
157
+ });
158
+
159
+ pi.registerCommand("usage", {
160
+ description: "Refresh, hide, or show the subscription bars (on | off | text)",
161
+ getArgumentCompletions: (prefix) =>
162
+ ["on", "off", "text"]
163
+ .filter((option) => option.startsWith(prefix))
164
+ .map((option) => ({ value: option, label: option })),
165
+ handler: async (args, ctx) => {
166
+ const action = args.trim().toLowerCase();
167
+ if (action === "off") {
168
+ showUsage = false;
169
+ apply(ctx);
170
+ requestRender?.();
171
+ ctx.ui.notify("Usage bars hidden. Use /usage on to restore them.", "info");
172
+ return;
173
+ }
174
+ if (action === "on") showUsage = true;
175
+ await refreshUsage(ctx, true);
176
+ apply(ctx);
177
+ if (action === "text" || !ctx.hasUI) ctx.ui.notify(usageSummaryText(usageState()), "info");
178
+ else if (usageState().errors.length > 0) ctx.ui.notify(usageState().errors.join("\n"), "warning");
179
+ },
180
+ });
181
+
182
+ }
@@ -0,0 +1,42 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerAccountProvider } from "../../core/accounts/registry.ts";
3
+ import { anthropicAccounts } from "./providers/anthropic.ts";
4
+ import { codexAccounts } from "./providers/codex.ts";
5
+ import { registerAnthropicProvider } from "./provider.ts";
6
+ import { registerAccountCommands } from "./accounts.ts";
7
+ import { registerRoutingCommands } from "./routing.ts";
8
+ import { registerFooter } from "./footer.ts";
9
+
10
+ /**
11
+ * Subscriptions domain: Claude OAuth accounts, routing mode, and the quota HUD.
12
+ *
13
+ * This replaces the old `claude-multi-account.ts` + `compact-footer.ts` pair.
14
+ * Account and routing commands are provider-agnostic: `/account <provider>` and
15
+ * `/routing` dispatch through core/accounts/registry.ts, so adding a second
16
+ * provider means writing one adapter, not new commands.
17
+ * Authentication and model catalogue are separate concerns here; they were only
18
+ * ever colocated because they shared an OAuth token.
19
+ *
20
+ * Relationship to @cortexkit/pi-anthropic-auth:
21
+ * That package stays installed and keeps ownership of the Anthropic provider,
22
+ * its stream implementation, and its own commands (/claude-account,
23
+ * /claude-routing, /claude-quota, ...). Absorbing it fully was evaluated and
24
+ * rejected, because its `dist/commands.js` exports only `registerCommands(pi)` as a
25
+ * unit, so taking over individual commands would mean copying ~130 lines of
26
+ * provider/model specs that drift on every vendor upgrade. This domain adds
27
+ * the multi-account surface the vendor does not provide, and everything it
28
+ * touches goes through src/vendor/anthropic.ts.
29
+ */
30
+ export default function subscriptions(pi: ExtensionAPI) {
31
+ // Adapters register first so the generic commands can see them.
32
+ registerAccountProvider(anthropicAccounts);
33
+ registerAccountProvider(codexAccounts);
34
+
35
+ // Own the Anthropic provider unless explicitly told to defer to cortexkit.
36
+ // PI_PLUS_VENDOR_ANTHROPIC=1 restores the vendored package.
37
+ if (process.env.PI_PLUS_VENDOR_ANTHROPIC !== "1") registerAnthropicProvider(pi);
38
+
39
+ registerAccountCommands(pi);
40
+ registerRoutingCommands(pi);
41
+ registerFooter(pi);
42
+ }