@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.
Files changed (133) hide show
  1. package/README.md +67 -22
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +9 -4
  4. package/src/core/accounts/oauth-pool.ts +137 -0
  5. package/src/core/accounts/registry.ts +12 -25
  6. package/src/core/accounts/routing.ts +144 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/identity.ts +54 -0
  9. package/src/core/anthropic/oauth.ts +3 -0
  10. package/src/core/anthropic/quota.ts +232 -78
  11. package/src/core/anthropic/routing.ts +40 -115
  12. package/src/core/anthropic/store.ts +11 -10
  13. package/src/core/catalog/quality.ts +33 -15
  14. package/src/core/codex/quota.ts +46 -8
  15. package/src/core/codex/store.ts +29 -16
  16. package/src/core/config.ts +16 -17
  17. package/src/core/store.ts +13 -12
  18. package/src/domains/agents/format.ts +97 -0
  19. package/src/domains/agents/index.ts +58 -47
  20. package/src/domains/compact/archive.ts +140 -0
  21. package/src/domains/compact/chunking.ts +166 -0
  22. package/src/domains/compact/index.ts +452 -0
  23. package/src/domains/compact/jev.ts +242 -0
  24. package/src/domains/compact/policy.ts +255 -0
  25. package/src/domains/compact/types.ts +79 -0
  26. package/src/domains/models/provider-picker.ts +1 -1
  27. package/src/domains/setup/index.ts +23 -23
  28. package/src/domains/subscriptions/accounts-picker.ts +61 -40
  29. package/src/domains/subscriptions/accounts.ts +53 -36
  30. package/src/domains/subscriptions/footer.ts +48 -29
  31. package/src/domains/subscriptions/index.ts +11 -27
  32. package/src/domains/subscriptions/provider.ts +79 -103
  33. package/src/domains/subscriptions/providers/anthropic.ts +42 -22
  34. package/src/domains/subscriptions/providers/codex.ts +146 -113
  35. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  36. package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
  37. package/src/domains/subscriptions/routing.ts +19 -11
  38. package/src/domains/workflows/LICENSE.md +21 -0
  39. package/src/domains/workflows/index.ts +836 -0
  40. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  41. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  42. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  43. package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
  44. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  45. package/src/domains/workflows/runtime/agent-limits.ts +66 -0
  46. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  47. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  48. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  49. package/src/domains/workflows/runtime/agent-runner.ts +225 -0
  50. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  51. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  52. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  53. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  54. package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
  55. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  56. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  57. package/src/domains/workflows/runtime/budget.ts +53 -0
  58. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  59. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  60. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  61. package/src/domains/workflows/runtime/debug.ts +3 -0
  62. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  63. package/src/domains/workflows/runtime/discovery.ts +137 -0
  64. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  65. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  66. package/src/domains/workflows/runtime/engine.ts +579 -0
  67. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  68. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  69. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  70. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  71. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  72. package/src/domains/workflows/runtime/journal.ts +313 -0
  73. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  74. package/src/domains/workflows/runtime/options.ts +157 -0
  75. package/src/domains/workflows/runtime/perf.ts +146 -0
  76. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  77. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  78. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  79. package/src/domains/workflows/runtime/progress.ts +400 -0
  80. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  81. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  82. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  83. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  84. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  85. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  86. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  87. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  88. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  89. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  90. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  91. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  92. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  93. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  94. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  95. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  96. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  97. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  98. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  99. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  100. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  101. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  102. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  103. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  104. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  105. package/src/domains/workflows/runtime/types.ts +255 -0
  106. package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
  107. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  108. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  109. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
  110. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  111. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  112. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  113. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  114. package/src/domains/workflows/runtime/usage.ts +341 -0
  115. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  116. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  117. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  118. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  119. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  120. package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
  121. package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
  122. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  123. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  124. package/src/domains/workflows/runtime/workflows.ts +40 -0
  125. package/src/domains/workflows/runtime/worktree.ts +615 -0
  126. package/src/domains/workflows/workflows/code-review.ts +232 -0
  127. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  128. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  129. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  130. package/src/domains/workflows/workflows/research.ts +169 -0
  131. package/src/services/usage-service.ts +6 -30
  132. package/src/ui/usage-bars.ts +1 -2
  133. package/src/core/codex/oauth.ts +0 -129
@@ -1,5 +1,10 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { accountRows, openAccountsPicker, type AccountState } from "./accounts-picker.ts";
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 provider.list();
65
- const routing = provider.routing ? ` · routing: ${await provider.routing.get()}` : "";
66
- blocks.push(
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 /account ${provider.id} add`]),
71
- );
97
+ : [` no additional accounts, add one with /accounts add ${provider.id}`]),
98
+ ];
72
99
  } catch (error) {
73
- blocks.push(`${provider.label} (${provider.id}): ${error instanceof Error ? error.message : String(error)}`);
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 /account ${provider.id} add.`, "info");
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(`Specify an account: /account ${provider.id} reauth <label>`, "warning");
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
- /** Adds an account: choose provider, name it, authorize. */
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 = await ctx.ui.input(`${provider.label} account name`, "Work, Personal, etc.");
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
- // The picker closes to run a wizard, then reopens so the result is
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
- 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
- }
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
- // Adapters register first so the generic commands can see them.
32
- registerAccountProvider(anthropicAccounts);
33
- registerAccountProvider(codexAccounts);
14
+ for (const provider of [anthropicAccounts, codexAccounts, kimiAccounts, xaiAccounts]) {
15
+ registerAccountProvider(provider);
16
+ }
34
17
 
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);
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 { applyQuotaHeaders, refreshAllQuota } from "../../core/anthropic/quota.ts";
8
- import { applyCodexQuotaHeaders } from "../../core/codex/quota.ts";
9
- import { loadCodexAccounts } from "../../core/codex/store.ts";
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
- * The Anthropic provider.
17
- *
18
- * Rather than reimplementing the Messages API, this registers pi's built-in
19
- * `anthropic-messages` implementation and supplies only the credential. pi
20
- * resolves auth per request, so `getApiKey` is the hook where account routing
21
- * happens: it returns the token of whichever account the router picked.
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
- { id: MAIN_ACCOUNT_ID, access: primary, quota: storage.main?.quota as any, order: 0 },
61
- ...storage.accounts
62
- .filter((a) => a.enabled !== false && a.type === "oauth" && a.access)
63
- .map((a, index) => ({ id: a.id, access: a.access, quota: a.quota, order: index + 1, account: a })),
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
- // `lastUsed` only orders the footer's account list, so second-level
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 core/anthropic/client-identity.ts. Without these, Anthropic bills
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
- // `main` lives in pi's auth.json, not in storage.accounts, so there is no
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
- * The same trick for Codex, which reports usage as `x-codex-*` headers.
183
- *
184
- * Codex has no usage endpoint, so headers are the ONLY quota source: an
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
- pi.on("after_provider_response", (event: any) => {
190
- const headers = event?.headers;
191
- if (!headers) return;
192
- const hasCodexQuota = Object.keys(headers).some((k) => k.toLowerCase().startsWith("x-codex-"));
193
- if (!hasCodexQuota) return;
194
- try {
195
- const sent = event?.request?.headers ?? {};
196
- const accountId = sent["chatgpt-account-id"] ?? sent["Chatgpt-Account-Id"];
197
- const match = loadCodexAccounts().accounts.find(
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
  }