@jameslovespancakes/pi-plus 1.0.11 → 1.0.13
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/README.md +67 -22
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +9 -4
- package/src/core/accounts/oauth-pool.ts +137 -0
- package/src/core/accounts/registry.ts +12 -25
- package/src/core/accounts/routing.ts +144 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +232 -78
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +11 -10
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +46 -8
- package/src/core/codex/store.ts +29 -16
- package/src/core/config.ts +16 -17
- package/src/core/store.ts +13 -12
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +61 -40
- package/src/domains/subscriptions/accounts.ts +53 -36
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +79 -103
- package/src/domains/subscriptions/providers/anthropic.ts +42 -22
- package/src/domains/subscriptions/providers/codex.ts +146 -113
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +66 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +225 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +579 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +615 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { readStoredCredential, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
accountRows,
|
|
4
|
+
openAccountsPicker,
|
|
5
|
+
providerAccounts,
|
|
6
|
+
type AccountState,
|
|
7
|
+
} from "./accounts-picker.ts";
|
|
3
8
|
import {
|
|
4
9
|
accountProvider,
|
|
5
10
|
accountProviders,
|
|
@@ -35,8 +40,10 @@ async function openBrowser(pi: ExtensionAPI, url: string): Promise<void> {
|
|
|
35
40
|
function bridge(pi: ExtensionAPI, ctx: any): AccountContext {
|
|
36
41
|
return {
|
|
37
42
|
hasUI: ctx.hasUI,
|
|
43
|
+
signal: ctx.signal,
|
|
38
44
|
ui: {
|
|
39
45
|
input: (title, placeholder) => ctx.ui.input(title, placeholder),
|
|
46
|
+
select: (title, options) => ctx.ui.select(title, options),
|
|
40
47
|
confirm: (title, message) => ctx.ui.confirm(title, message),
|
|
41
48
|
notify: (message, type) => ctx.ui.notify(message, type ?? "info"),
|
|
42
49
|
},
|
|
@@ -44,11 +51,29 @@ function bridge(pi: ExtensionAPI, ctx: any): AccountContext {
|
|
|
44
51
|
};
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
async function primaryAccount(provider: AccountProvider): Promise<ManagedAccount | undefined> {
|
|
55
|
+
try {
|
|
56
|
+
const credential = readStoredCredential(provider.id);
|
|
57
|
+
if (!credential) return undefined;
|
|
58
|
+
return {
|
|
59
|
+
id: "main",
|
|
60
|
+
label: "Primary",
|
|
61
|
+
enabled: true,
|
|
62
|
+
expiresAt: credential.type === "oauth" ? credential.expires : undefined,
|
|
63
|
+
primary: true,
|
|
64
|
+
identity: credential.type === "oauth" ? await provider.identify?.(credential.access) : undefined,
|
|
65
|
+
};
|
|
66
|
+
} catch {
|
|
67
|
+
// A malformed/unreadable auth file must not hide the sidecar accounts.
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
function describe(account: ManagedAccount): string {
|
|
48
73
|
const state = !account.enabled
|
|
49
74
|
? "disabled"
|
|
50
75
|
: account.expiresAt !== undefined && account.expiresAt < Date.now() ? "expired" : "active";
|
|
51
|
-
return `${account.label.padEnd(20)} ${state}`;
|
|
76
|
+
return `${account.label.padEnd(20)} ${state}${account.primary ? " · primary" : ""}`;
|
|
52
77
|
}
|
|
53
78
|
|
|
54
79
|
async function listAll(ctx: any): Promise<void> {
|
|
@@ -58,34 +83,34 @@ async function listAll(ctx: any): Promise<void> {
|
|
|
58
83
|
return;
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
const blocks: string[]
|
|
62
|
-
for (const provider of providers) {
|
|
86
|
+
const blocks = await Promise.all(providers.map(async (provider): Promise<string[]> => {
|
|
63
87
|
try {
|
|
64
|
-
const accounts = await
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
const [accounts, mode] = await Promise.all([
|
|
89
|
+
providerAccounts(provider, primaryAccount),
|
|
90
|
+
provider.routing?.get(),
|
|
91
|
+
]);
|
|
92
|
+
const routing = mode ? ` · routing: ${mode}` : "";
|
|
93
|
+
return [
|
|
67
94
|
`${provider.label} (${provider.id})${routing}`,
|
|
68
95
|
...(accounts.length > 0
|
|
69
96
|
? accounts.map((account) => ` ${describe(account)}`)
|
|
70
|
-
: [` no additional accounts, add one with /
|
|
71
|
-
|
|
97
|
+
: [` no additional accounts, add one with /accounts add ${provider.id}`]),
|
|
98
|
+
];
|
|
72
99
|
} catch (error) {
|
|
73
|
-
|
|
100
|
+
return [`${provider.label} (${provider.id}): ${error instanceof Error ? error.message : String(error)}`];
|
|
74
101
|
}
|
|
75
|
-
}
|
|
76
|
-
ctx.ui.notify(blocks.join("\n"), "info");
|
|
102
|
+
}));
|
|
103
|
+
ctx.ui.notify(blocks.flat().join("\n"), "info");
|
|
77
104
|
}
|
|
78
105
|
|
|
79
|
-
|
|
80
|
-
|
|
81
106
|
async function pickAccount(ctx: any, provider: AccountProvider): Promise<string | undefined> {
|
|
82
|
-
const accounts = await provider.list();
|
|
107
|
+
const accounts = (await provider.list()).filter((account) => !account.primary);
|
|
83
108
|
if (accounts.length === 0) {
|
|
84
|
-
ctx.ui.notify(`No ${provider.label} accounts yet. Add one with /
|
|
109
|
+
ctx.ui.notify(`No ${provider.label} accounts yet. Add one with /accounts add ${provider.id}.`, "info");
|
|
85
110
|
return undefined;
|
|
86
111
|
}
|
|
87
112
|
if (!ctx.hasUI) {
|
|
88
|
-
ctx.ui.notify(`
|
|
113
|
+
ctx.ui.notify(`Choose an account with /accounts reauth ${provider.id}.`, "warning");
|
|
89
114
|
return undefined;
|
|
90
115
|
}
|
|
91
116
|
|
|
@@ -96,30 +121,25 @@ async function pickAccount(ctx: any, provider: AccountProvider): Promise<string
|
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
99
|
-
/** Toggles one account and returns the state it ended in. */
|
|
100
124
|
const toggle = (providerId: string, accountId: string): AccountState => {
|
|
101
125
|
const provider = accountProvider(providerId);
|
|
102
126
|
if (!provider?.setEnabled) throw new Error(`${providerId} accounts cannot be disabled.`);
|
|
103
127
|
const next = pendingState.get(`${providerId}:${accountId}`) === "enabled" ? "disabled" : "enabled";
|
|
104
128
|
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
129
|
void provider.setEnabled(accountId, next === "enabled").catch(() => {});
|
|
108
130
|
return next;
|
|
109
131
|
};
|
|
110
132
|
|
|
111
|
-
/** Mirror of on-disk state, so the synchronous toggle can answer instantly. */
|
|
112
133
|
const pendingState = new Map<string, AccountState>();
|
|
113
134
|
|
|
114
135
|
const rows = async () => {
|
|
115
|
-
const list = await accountRows(accountProviders());
|
|
136
|
+
const list = await accountRows(accountProviders(), primaryAccount);
|
|
116
137
|
pendingState.clear();
|
|
117
138
|
for (const row of list) pendingState.set(row.id, row.state);
|
|
118
139
|
return list;
|
|
119
140
|
};
|
|
120
141
|
|
|
121
|
-
|
|
122
|
-
async function addAccount(ctx: any, providerId?: string): Promise<void> {
|
|
142
|
+
async function addAccount(ctx: any, providerId?: string, requestedLabel?: string): Promise<void> {
|
|
123
143
|
if (!ctx.hasUI) {
|
|
124
144
|
ctx.ui.notify("Adding an account requires interactive Pi mode.", "error");
|
|
125
145
|
return;
|
|
@@ -144,7 +164,8 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
144
164
|
}
|
|
145
165
|
if (!provider) return;
|
|
146
166
|
|
|
147
|
-
const label =
|
|
167
|
+
const label = requestedLabel?.trim()
|
|
168
|
+
|| await ctx.ui.input(`${provider.label} account name`, "Work, Personal, etc.");
|
|
148
169
|
if (!label?.trim()) return;
|
|
149
170
|
|
|
150
171
|
try {
|
|
@@ -155,14 +176,13 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
155
176
|
}
|
|
156
177
|
}
|
|
157
178
|
|
|
158
|
-
/** Rename wizard: pick an account, type a new name. */
|
|
159
179
|
async function renameAccount(ctx: any): Promise<void> {
|
|
160
180
|
if (!ctx.hasUI) {
|
|
161
181
|
ctx.ui.notify("Renaming an account requires interactive Pi mode.", "error");
|
|
162
182
|
return;
|
|
163
183
|
}
|
|
164
|
-
const list = await accountRows(accountProviders());
|
|
165
|
-
const renameable = list.filter((row) => accountProvider(row.providerId)?.rename);
|
|
184
|
+
const list = await accountRows(accountProviders(), primaryAccount);
|
|
185
|
+
const renameable = list.filter((row) => !row.primary && accountProvider(row.providerId)?.rename);
|
|
166
186
|
if (renameable.length === 0) {
|
|
167
187
|
ctx.ui.notify("No accounts can be renamed.", "info");
|
|
168
188
|
return;
|
|
@@ -179,7 +199,6 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
179
199
|
|
|
180
200
|
const provider = accountProvider(row.providerId);
|
|
181
201
|
try {
|
|
182
|
-
// Row ids are "<providerId>:<accountId>"; strip the prefix.
|
|
183
202
|
await provider!.rename!(row.id.slice(row.providerId.length + 1), next.trim());
|
|
184
203
|
ctx.ui.notify(`Renamed to “${next.trim()}”.`, "info");
|
|
185
204
|
} catch (error) {
|
|
@@ -202,15 +221,15 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
202
221
|
.map((id) => ({ value: `${parts[0]} ${id}`, label: id }));
|
|
203
222
|
},
|
|
204
223
|
handler: async (args, ctx) => {
|
|
205
|
-
const [action, providerId] = args.trim().split(/\s+/).filter(Boolean);
|
|
224
|
+
const [action, providerId, ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
206
225
|
|
|
207
|
-
if (action === "add") return addAccount(ctx, providerId);
|
|
226
|
+
if (action === "add") return addAccount(ctx, providerId, rest.join(" ") || undefined);
|
|
208
227
|
if (action === "rename") return renameAccount(ctx);
|
|
209
228
|
|
|
210
229
|
if (action === "reauth") {
|
|
211
230
|
const provider = providerId ? accountProvider(providerId) : accountProviders()[0];
|
|
212
231
|
if (!provider) { ctx.ui.notify("No subscription providers are registered.", "error"); return; }
|
|
213
|
-
const target = await pickAccount(ctx, provider);
|
|
232
|
+
const target = rest.join(" ") || await pickAccount(ctx, provider);
|
|
214
233
|
if (!target) return;
|
|
215
234
|
try {
|
|
216
235
|
const done = await provider.reauth(bridge(pi, ctx), target);
|
|
@@ -226,11 +245,9 @@ export function registerAccountCommands(pi: ExtensionAPI): void {
|
|
|
226
245
|
return;
|
|
227
246
|
}
|
|
228
247
|
|
|
229
|
-
// Bare /accounts: the picker interactively, plain text headless.
|
|
230
248
|
if (!ctx.hasUI) return listAll(ctx);
|
|
231
249
|
|
|
232
|
-
//
|
|
233
|
-
// visible immediately. Bounded so a misbehaving action cannot spin.
|
|
250
|
+
// Reopen after each wizard so changes appear immediately.
|
|
234
251
|
for (let step = 0; step < 24; step++) {
|
|
235
252
|
const requested = await openAccountsPicker(ctx, { rows, toggle });
|
|
236
253
|
if (!requested) return;
|
|
@@ -4,20 +4,55 @@ import { refreshUsage, startPolling, stopPolling, subscribe, usageState } from "
|
|
|
4
4
|
import { renderUsageLines, usageSummaryText } from "../../ui/usage-bars.ts";
|
|
5
5
|
import { formatTokens, sanitize } from "../../ui/format.ts";
|
|
6
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
|
-
*/
|
|
7
|
+
/** Compact session footer with shared subscription usage bars. */
|
|
14
8
|
|
|
15
|
-
const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "kimi-coding"]);
|
|
9
|
+
const SUBSCRIPTION_PROVIDERS = new Set(["anthropic", "openai-codex", "kimi-coding", "xai"]);
|
|
10
|
+
|
|
11
|
+
interface SessionTotals {
|
|
12
|
+
input: number;
|
|
13
|
+
output: number;
|
|
14
|
+
cacheRead: number;
|
|
15
|
+
cacheWrite: number;
|
|
16
|
+
cost: number;
|
|
17
|
+
latestHitRate?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function usageFor(entry: any): any {
|
|
21
|
+
if (entry.type === "message") {
|
|
22
|
+
return entry.message.role === "assistant" || entry.message.role === "toolResult" ? entry.message.usage : undefined;
|
|
23
|
+
}
|
|
24
|
+
return entry.type === "branch_summary" || entry.type === "compaction" ? entry.usage : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sumSessionUsage(entries: any[]): SessionTotals {
|
|
28
|
+
const totals: SessionTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const usage = usageFor(entry);
|
|
31
|
+
if (!usage) continue;
|
|
32
|
+
totals.input += usage.input ?? 0;
|
|
33
|
+
totals.output += usage.output ?? 0;
|
|
34
|
+
totals.cacheRead += usage.cacheRead ?? 0;
|
|
35
|
+
totals.cacheWrite += usage.cacheWrite ?? 0;
|
|
36
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
37
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
38
|
+
const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
39
|
+
totals.latestHitRate = prompt > 0 ? ((usage.cacheRead ?? 0) / prompt) * 100 : undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return totals;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function usageSignature(entries: any[]): string {
|
|
46
|
+
const last = entries.at(-1);
|
|
47
|
+
const usage = last ? usageFor(last) : undefined;
|
|
48
|
+
return [entries.length, last?.id, usage?.input, usage?.output, usage?.cacheRead, usage?.cacheWrite, usage?.cost?.total].join(":");
|
|
49
|
+
}
|
|
16
50
|
|
|
17
51
|
export function registerFooter(pi: ExtensionAPI): void {
|
|
18
52
|
let showUsage = true;
|
|
19
53
|
let requestRender: (() => void) | undefined;
|
|
20
54
|
let unsubscribe: (() => void) | undefined;
|
|
55
|
+
let cachedUsage: { signature: string; totals: SessionTotals } | undefined;
|
|
21
56
|
|
|
22
57
|
const apply = (ctx: any) => {
|
|
23
58
|
if (!ctx.hasUI) return;
|
|
@@ -34,28 +69,12 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
34
69
|
const width = Math.max(1, Math.floor(Number(rawWidth) || 0));
|
|
35
70
|
const lines: string[] = [];
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
}
|
|
72
|
+
const entries = ctx.sessionManager.getEntries();
|
|
73
|
+
const signature = usageSignature(entries);
|
|
74
|
+
if (cachedUsage?.signature !== signature) {
|
|
75
|
+
cachedUsage = { signature, totals: sumSessionUsage(entries) };
|
|
58
76
|
}
|
|
77
|
+
const { input, output, cacheRead, cacheWrite, cost, latestHitRate } = cachedUsage.totals;
|
|
59
78
|
|
|
60
79
|
const parts: string[] = [];
|
|
61
80
|
if (input) parts.push(`↑${formatTokens(input)}`);
|
|
@@ -1,40 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { registerAccountProvider } from "../../core/accounts/registry.ts";
|
|
3
3
|
import { anthropicAccounts } from "./providers/anthropic.ts";
|
|
4
|
-
import { codexAccounts } from "./providers/codex.ts";
|
|
4
|
+
import { codexAccounts, CODEX_SPEC } from "./providers/codex.ts";
|
|
5
|
+
import { kimiAccounts, KIMI_SPEC, xaiAccounts, XAI_SPEC } from "./providers/hosted.ts";
|
|
6
|
+
import { registerPooledOAuthProvider } from "./providers/oauth-pool.ts";
|
|
5
7
|
import { registerAnthropicProvider } from "./provider.ts";
|
|
6
8
|
import { registerAccountCommands } from "./accounts.ts";
|
|
7
9
|
import { registerRoutingCommands } from "./routing.ts";
|
|
8
10
|
import { registerFooter } from "./footer.ts";
|
|
9
11
|
|
|
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
|
-
*/
|
|
12
|
+
/** Registers subscription providers, pooled accounts, routing, and usage UI. */
|
|
30
13
|
export default function subscriptions(pi: ExtensionAPI) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
14
|
+
for (const provider of [anthropicAccounts, codexAccounts, kimiAccounts, xaiAccounts]) {
|
|
15
|
+
registerAccountProvider(provider);
|
|
16
|
+
}
|
|
34
17
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
18
|
+
registerAnthropicProvider(pi);
|
|
19
|
+
registerPooledOAuthProvider(pi, CODEX_SPEC);
|
|
20
|
+
registerPooledOAuthProvider(pi, KIMI_SPEC);
|
|
21
|
+
registerPooledOAuthProvider(pi, XAI_SPEC);
|
|
38
22
|
|
|
39
23
|
registerAccountCommands(pi);
|
|
40
24
|
registerRoutingCommands(pi);
|
|
@@ -3,64 +3,83 @@ import { authorize, exchange, refreshToken } from "../../core/anthropic/oauth.ts
|
|
|
3
3
|
import {
|
|
4
4
|
billingHeader, clientIdentityHeaders, firstUserText, prependPromptBlock, signRequestBody, splitSystemPrompt,
|
|
5
5
|
} from "../../core/anthropic/client-identity.ts";
|
|
6
|
+
import {
|
|
7
|
+
anthropicAccountIdentity,
|
|
8
|
+
cachedAnthropicAccountIdentity,
|
|
9
|
+
} from "../../core/anthropic/identity.ts";
|
|
6
10
|
import { ANTHROPIC_MODELS } from "../../core/anthropic/models.ts";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
import {
|
|
12
|
+
ACCESS_REFRESH_INTERVAL_MS,
|
|
13
|
+
applyQuotaHeaders,
|
|
14
|
+
refreshAllQuota,
|
|
15
|
+
} from "../../core/anthropic/quota.ts";
|
|
10
16
|
import {
|
|
11
17
|
MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
|
|
12
18
|
} from "../../core/anthropic/routing.ts";
|
|
13
19
|
import { getRoutingMode, loadAccounts, saveAccount } from "../../core/anthropic/store.ts";
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* Consequences of that choice, stated plainly:
|
|
24
|
-
* - `getApiKey` is synchronous, so selection reads the CACHED quota snapshot.
|
|
25
|
-
* A background timer keeps it fresh instead of polling inline.
|
|
26
|
-
* - A mid-stream 429 is handled by pi's retry rather than by migrating the
|
|
27
|
-
* in-flight request to another account. The next request routes elsewhere
|
|
28
|
-
* once the failure is recorded, which is a real behavioural difference from
|
|
29
|
-
* the vendored provider.
|
|
30
|
-
*
|
|
31
|
-
* What this buys: pi's streaming, tool conversion, cost accounting and retry
|
|
32
|
-
* logic are reused unchanged, instead of a 1,500-line reimplementation whose
|
|
33
|
-
* edge cases we could not see.
|
|
22
|
+
* Anthropic uses pi's Messages API client with per-request account routing.
|
|
23
|
+
* Routing reads cached quota; a 429 affects the next request, not its stream.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The hook is global, so require both Anthropic's payload shape and a Claude
|
|
28
|
+
* model. This prevents `system` injection into other provider requests.
|
|
34
29
|
*/
|
|
30
|
+
function isAnthropicMessagesPayload(payload: any): boolean {
|
|
31
|
+
if ("instructions" in payload || "input" in payload) return false;
|
|
32
|
+
if (!Array.isArray(payload.messages)) return false;
|
|
33
|
+
const model = typeof payload.model === "string" ? payload.model.toLowerCase() : "";
|
|
34
|
+
return model.startsWith("claude") || ANTHROPIC_MODELS.some((m: any) => m.id === payload.model);
|
|
35
|
+
}
|
|
35
36
|
|
|
36
37
|
let lastSelected: { id: string; at: number } | undefined;
|
|
38
|
+
const accountLastUsed = new Map<string, number>();
|
|
37
39
|
|
|
38
40
|
/** Which account served the most recent request, for the UI. */
|
|
39
41
|
export function lastRoutedAccount(): { id: string; at: number } | undefined {
|
|
40
42
|
return lastSelected;
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
/**
|
|
44
|
-
* Chooses an account and returns its access token.
|
|
45
|
-
*
|
|
46
|
-
* `primary` is pi's own credential, which participates as `main` at order 0.
|
|
47
|
-
* Falls back to the primary token whenever routing has nothing better, so a
|
|
48
|
-
* single-account setup behaves exactly as it did before any of this existed.
|
|
49
|
-
*/
|
|
50
|
-
// `_sessionId` is accepted for signature compatibility with pi's per-request
|
|
51
|
-
// hook; stickiness is derived from stored assignments rather than the id.
|
|
45
|
+
/** Routes to a pooled account, falling back to pi's primary credential. */
|
|
52
46
|
export function routeAccessToken(primary: string, modelId?: string, _sessionId?: string): string {
|
|
53
47
|
const storage = loadAccounts();
|
|
54
48
|
if (!storage) return primary;
|
|
55
49
|
|
|
56
50
|
const mode = getRoutingMode(storage);
|
|
57
51
|
const family = familyForModel(modelId);
|
|
52
|
+
const primaryIdentity = cachedAnthropicAccountIdentity(primary);
|
|
53
|
+
if (!primaryIdentity) void anthropicAccountIdentity(primary).catch(() => {});
|
|
54
|
+
const identities = new Set(primaryIdentity ? [primaryIdentity] : []);
|
|
55
|
+
const sidecars = storage.accounts.filter((account) => {
|
|
56
|
+
if (account.enabled === false || account.type !== "oauth" || !account.access
|
|
57
|
+
|| typeof account.expires !== "number" || account.expires <= Date.now()) return false;
|
|
58
|
+
if (!account.identity) return true;
|
|
59
|
+
if (identities.has(account.identity)) return false;
|
|
60
|
+
identities.add(account.identity);
|
|
61
|
+
return true;
|
|
62
|
+
});
|
|
58
63
|
|
|
59
64
|
const candidates: Candidate[] = [
|
|
60
|
-
{
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
{
|
|
66
|
+
id: MAIN_ACCOUNT_ID,
|
|
67
|
+
access: primary,
|
|
68
|
+
quota: storage.main?.quota as any,
|
|
69
|
+
order: 0,
|
|
70
|
+
lastUsed: accountLastUsed.get(MAIN_ACCOUNT_ID) ?? Number(storage.main?.lastUsed ?? 0),
|
|
71
|
+
},
|
|
72
|
+
// Never route an expired/unknown-lifetime sidecar while its asynchronous
|
|
73
|
+
// refresher catches up; falling back to pi's primary is safer than a 401.
|
|
74
|
+
// Stable identities also keep duplicate logins from inflating the pool.
|
|
75
|
+
...sidecars.map((a, index) => ({
|
|
76
|
+
id: a.id,
|
|
77
|
+
access: a.access,
|
|
78
|
+
quota: a.quota,
|
|
79
|
+
order: index + 1,
|
|
80
|
+
lastUsed: accountLastUsed.get(a.id) ?? a.lastUsed ?? 0,
|
|
81
|
+
account: a,
|
|
82
|
+
})),
|
|
64
83
|
];
|
|
65
84
|
|
|
66
85
|
// Nothing to choose between: keep pi's own credential.
|
|
@@ -75,10 +94,9 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
|
|
|
75
94
|
|
|
76
95
|
const now = Date.now();
|
|
77
96
|
lastSelected = { id: picked.candidate.id, at: now };
|
|
97
|
+
accountLastUsed.set(picked.candidate.id, now);
|
|
78
98
|
if (picked.candidate.account) {
|
|
79
|
-
//
|
|
80
|
-
// precision buys nothing. Persisting it on every request rewrote two
|
|
81
|
-
// credential files per call; once a minute is indistinguishable in the UI.
|
|
99
|
+
// Minute precision avoids a credential write on every request.
|
|
82
100
|
const previous = picked.candidate.account.lastUsed ?? 0;
|
|
83
101
|
if (now - previous > 60_000) {
|
|
84
102
|
saveAccount({ ...picked.candidate.account, lastUsed: now });
|
|
@@ -102,9 +120,7 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
102
120
|
baseUrl: "https://api.anthropic.com",
|
|
103
121
|
api: "anthropic-messages",
|
|
104
122
|
models: ANTHROPIC_MODELS,
|
|
105
|
-
// See
|
|
106
|
-
// requests to extra usage rather than plan limits. Delete that import and
|
|
107
|
-
// this line to opt out; nothing else depends on it.
|
|
123
|
+
// Required for subscription billing. See client-identity.ts.
|
|
108
124
|
headers: clientIdentityHeaders(),
|
|
109
125
|
oauth: {
|
|
110
126
|
name: "Anthropic Claude Pro/Max",
|
|
@@ -114,29 +130,15 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
114
130
|
const refreshed = await refreshToken({ refreshToken: credentials.refresh });
|
|
115
131
|
return { access: refreshed.access, refresh: refreshed.refresh, expires: refreshed.expires };
|
|
116
132
|
},
|
|
117
|
-
// The routing hook. pi calls this per request.
|
|
118
133
|
getApiKey: (credentials: any) => routeAccessToken(credentials.access),
|
|
119
134
|
},
|
|
120
135
|
});
|
|
121
136
|
|
|
122
|
-
/**
|
|
123
|
-
* Reshapes the request the way the endpoint requires, then signs it.
|
|
124
|
-
*
|
|
125
|
-
* Three things have to happen, in order:
|
|
126
|
-
* 1. pi's system prompt is split. The documentation paragraph cannot live in
|
|
127
|
-
* `system` at all, or Anthropic answers 400 "Third-party apps now draw
|
|
128
|
-
* from your extra usage"; it moves into the first user message instead.
|
|
129
|
-
* 2. The billing header goes first in `system`, alongside the Claude Code
|
|
130
|
-
* identity line.
|
|
131
|
-
* 3. The body is signed, which must happen last because the checksum covers
|
|
132
|
-
* the canonicalised body.
|
|
133
|
-
*
|
|
134
|
-
* See core/anthropic/client-identity.ts for what all of this is and the
|
|
135
|
-
* caveats around it.
|
|
136
|
-
*/
|
|
137
|
+
/** Rebuilds the Anthropic prompt, then signs the final body. */
|
|
137
138
|
pi.on("before_provider_request", async (event: any) => {
|
|
138
139
|
const payload = event?.payload;
|
|
139
140
|
if (!payload || typeof payload !== "object") return undefined;
|
|
141
|
+
if (!isAnthropicMessagesPayload(payload)) return undefined;
|
|
140
142
|
|
|
141
143
|
const serialized = typeof payload.system === "string"
|
|
142
144
|
? payload.system
|
|
@@ -156,20 +158,10 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
156
158
|
return JSON.parse(signed);
|
|
157
159
|
});
|
|
158
160
|
|
|
159
|
-
/**
|
|
160
|
-
* Free quota refresh for whichever account just served a request.
|
|
161
|
-
*
|
|
162
|
-
* Anthropic returns the same utilisation numbers as the usage endpoint in
|
|
163
|
-
* `anthropic-ratelimit-unified-*` response headers, so the active account
|
|
164
|
-
* stays current without spending a request on asking.
|
|
165
|
-
*
|
|
166
|
-
* `lastRoutedAccount()` is set by `getApiKey` immediately before the call,
|
|
167
|
-
* which is what lets the reply be attributed to the right account.
|
|
168
|
-
*/
|
|
161
|
+
/** Updates the routed account from response quota headers. */
|
|
169
162
|
pi.on("after_provider_response", (event: any) => {
|
|
170
163
|
const routed = lastRoutedAccount();
|
|
171
|
-
//
|
|
172
|
-
// per-account record to update for it.
|
|
164
|
+
// The primary account lives in pi's auth store.
|
|
173
165
|
if (!routed || routed.id === MAIN_ACCOUNT_ID) return;
|
|
174
166
|
try {
|
|
175
167
|
applyQuotaHeaders(routed.id, event?.headers);
|
|
@@ -179,48 +171,32 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
179
171
|
});
|
|
180
172
|
|
|
181
173
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* account that is not serving traffic keeps whatever snapshot it last had.
|
|
186
|
-
* Attribution is by `chatgpt-account-id` on the request, since Codex
|
|
187
|
-
* requests carry the account explicitly rather than only in the token.
|
|
174
|
+
* Keep rotating sidecar credentials even while their quota snapshot is fresh.
|
|
175
|
+
* CortexKit refreshes fallbacks on an independent background cadence for the
|
|
176
|
+
* same reason: quota freshness is not credential freshness.
|
|
188
177
|
*/
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
(a) => (accountId ? a.accountId === accountId : false) || a.enabled !== false,
|
|
199
|
-
);
|
|
200
|
-
if (match) applyCodexQuotaHeaders(match.id, headers);
|
|
201
|
-
} catch {
|
|
202
|
-
// Bookkeeping only.
|
|
203
|
-
}
|
|
204
|
-
});
|
|
178
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
179
|
+
const startRefreshLoop = () => {
|
|
180
|
+
if (refreshTimer) return;
|
|
181
|
+
refreshTimer = setInterval(
|
|
182
|
+
() => void refreshAllQuota().catch(() => {}),
|
|
183
|
+
ACCESS_REFRESH_INTERVAL_MS + Math.floor(Math.random() * 30_000),
|
|
184
|
+
);
|
|
185
|
+
refreshTimer.unref?.();
|
|
186
|
+
};
|
|
205
187
|
|
|
206
|
-
/**
|
|
207
|
-
* Idle accounts still need polling, because routing compares accounts and
|
|
208
|
-
* response headers only ever refresh the one that served traffic.
|
|
209
|
-
*
|
|
210
|
-
* Triggered by sending a message, not by a timer, and `refreshAllQuota`
|
|
211
|
-
* skips accounts whose snapshot is still fresh. So the poll rate is at most
|
|
212
|
-
* once per QUOTA_FRESH_MS however fast messages are sent, and an idle
|
|
213
|
-
* session costs nothing at all. The previous 5 minute interval ran forever
|
|
214
|
-
* regardless of activity and was being answered with 429s.
|
|
215
|
-
*
|
|
216
|
-
* `input` is the per-message hook; `turn_start` would fire once per LLM
|
|
217
|
-
* response, which is many times per message in a tool-use loop.
|
|
218
|
-
*/
|
|
219
188
|
pi.on("input", async () => {
|
|
189
|
+
startRefreshLoop();
|
|
220
190
|
void refreshAllQuota().catch(() => {});
|
|
221
191
|
});
|
|
222
192
|
|
|
223
193
|
pi.on("session_start", async () => {
|
|
194
|
+
startRefreshLoop();
|
|
224
195
|
void refreshAllQuota().catch(() => {});
|
|
225
196
|
});
|
|
197
|
+
|
|
198
|
+
pi.on("session_shutdown", async () => {
|
|
199
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
200
|
+
refreshTimer = undefined;
|
|
201
|
+
});
|
|
226
202
|
}
|