@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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 (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
@@ -1,33 +1,11 @@
1
1
  import { loadAccounts, saveAccount, type Account, type QuotaSnapshot } from "./store.ts";
2
2
  import { refreshToken } from "./oauth.ts";
3
3
 
4
- /**
5
- * Quota tracking.
6
- *
7
- * There are two sources, and the cheap one is strongly preferred:
8
- *
9
- * 1. Response headers. Every `/v1/messages` reply carries the same numbers
10
- * as the usage endpoint, so the account actually serving traffic keeps
11
- * its snapshot current at no request cost at all.
12
- * 2. The usage endpoint. Only needed for accounts that are NOT serving
13
- * traffic, since routing compares accounts and an idle one would
14
- * otherwise never update.
15
- *
16
- * Polling used to run on a 5 minute timer regardless of activity. That is what
17
- * the backoff below exists for: the endpoint rate limits, it answers 429 with
18
- * `retry-after: 0`, and `pollQuota` reports failure as `undefined`, so the
19
- * throttling was invisible and simply left routing on stale data.
20
- */
4
+ /** Uses free response headers first, polling only stale idle accounts. */
21
5
 
22
6
  const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
23
7
  const TIMEOUT_MS = 10_000;
24
- /**
25
- * Selection tolerates a snapshot this old before it is worth re-polling.
26
- *
27
- * This doubles as the poll rate limiter. Polling is triggered by sending a
28
- * message, so this is the floor between polls however fast you type: send a
29
- * message after the window and it polls, send ten inside it and it polls once.
30
- */
8
+ /** Snapshot lifetime and minimum interval between polls. */
31
9
  export const QUOTA_FRESH_MS = 10 * 60_000;
32
10
  /** After a 429, wait at least this long before touching the endpoint again. */
33
11
  export const QUOTA_BACKOFF_MS = 15 * 60_000;
@@ -95,15 +73,8 @@ export function isFresh(quota: QuotaSnapshot | undefined, now = Date.now()): boo
95
73
  }
96
74
 
97
75
  /**
98
- * Reads a quota snapshot out of `/v1/messages` response headers.
99
- *
100
- * Anthropic reports utilisation here as a FRACTION (`0.16`), while the usage
101
- * endpoint reports a PERCENT (`16`). Verified against the same account at the
102
- * same moment, including matching reset timestamps. Scaling by 100 is what
103
- * makes the two sources comparable, so do not drop it.
104
- *
105
- * Returns undefined when the headers are absent, which is normal: they do not
106
- * appear on 4xx replies, and non-Anthropic transports may not expose them.
76
+ * Parses response quota headers. Header utilization is a fraction, while the
77
+ * usage endpoint returns a percentage, so header values are scaled by 100.
107
78
  */
108
79
  export function parseQuotaHeaders(
109
80
  headers: Record<string, unknown> | undefined,
@@ -145,12 +116,7 @@ export function parseQuotaHeaders(
145
116
  return { five_hour, seven_day, checkedAt: now, source: "headers" };
146
117
  }
147
118
 
148
- /**
149
- * Merges a header-derived snapshot into an account.
150
- *
151
- * Scoped per-model limits only come from the usage endpoint, so they are
152
- * carried over from the previous snapshot rather than dropped.
153
- */
119
+ /** Merges header quota while preserving polled model limits. */
154
120
  export function applyQuotaHeaders(
155
121
  accountId: string,
156
122
  headers: Record<string, unknown> | undefined,
@@ -163,10 +129,7 @@ export function applyQuotaHeaders(
163
129
  const account = storage?.accounts.find((a) => a.id === accountId);
164
130
  if (!account) return false;
165
131
 
166
- // Headers arrive on every response, but utilisation moves in whole percent
167
- // steps over windows of hours. Rewriting two credential files per request to
168
- // store an unchanged number is pure write amplification, and on Windows each
169
- // rewrite is another chance for the rename to collide with a file lock.
132
+ // Skip unchanged values to avoid credential writes on every response.
170
133
  const previous = account.quota;
171
134
  const unchanged =
172
135
  previous?.five_hour?.usedPercent === fresh.five_hour?.usedPercent
@@ -177,12 +140,7 @@ export function applyQuotaHeaders(
177
140
  return true;
178
141
  }
179
142
 
180
- /**
181
- * Polls one account. Returns undefined rather than throwing on failure.
182
- *
183
- * Pass `accountId` so a 429 registers backoff; without it the caller can
184
- * hammer a throttled endpoint and silently keep stale quota.
185
- */
143
+ /** Polls one account and records rate-limit backoff. */
186
144
  export async function pollQuota(
187
145
  accessToken: string,
188
146
  accountId?: string,
@@ -232,10 +190,7 @@ export async function ensureAccessToken(account: Account): Promise<string | unde
232
190
  return refreshed.access;
233
191
  }
234
192
 
235
- /**
236
- * Refreshes stale quota for every usable account, in parallel.
237
- * Best-effort: a failure leaves the previous snapshot in place.
238
- */
193
+ /** Refreshes stale account quota in parallel. */
239
194
  export async function refreshAllQuota(force = false): Promise<number> {
240
195
  const storage = loadAccounts();
241
196
  if (!storage) return 0;
@@ -1,17 +1,7 @@
1
+ import { selectRoutingCandidate, type AccountQuotaState } from "../accounts/routing.ts";
1
2
  import { MAIN_ACCOUNT_ID, type Account, type QuotaSnapshot, type QuotaWindow, type RoutingMode } from "./store.ts";
2
3
 
3
- /**
4
- * Account selection and sticky session assignment.
5
- *
6
- * Two jobs:
7
- * 1. score each candidate by how much work it can absorb
8
- * 2. keep a session on one account, because Anthropic's prompt cache is
9
- * per-account and migrating throws it away
10
- */
11
-
12
- /** How long each window takes to refill completely. */
13
- export const WINDOW_HOURS = { five_hour: 5, seven_day: 168, scoped: 168 } as const;
14
- export type WindowKey = keyof typeof WINDOW_HOURS;
4
+ /** Claude quota normalization and account selection. */
15
5
 
16
6
  export type Family = "fable" | "opus" | "general";
17
7
 
@@ -28,25 +18,10 @@ export interface Candidate {
28
18
  quota?: QuotaSnapshot;
29
19
  /** Config order; `main` is 0, so it wins ties. */
30
20
  order: number;
21
+ lastUsed?: number;
31
22
  account?: Account;
32
23
  }
33
24
 
34
- /**
35
- * Hours of wall-clock recovery per point of quota spent.
36
- *
37
- * Expressed against the window's own length rather than its distance to reset:
38
- * a 5h window regenerates 34x faster than a 7d one, so a point spent there is
39
- * far cheaper. Returns Infinity at or below zero, which collapses the account's
40
- * weight to 0 and removes it from selection.
41
- */
42
- export function recoveryCost(window: QuotaWindow | undefined, key: WindowKey, reserve = 0): number {
43
- const remaining = window?.remainingPercent;
44
- if (!Number.isFinite(remaining)) return Infinity;
45
- const spendable = Math.max(0, (remaining as number) - reserve);
46
- if (spendable <= 0) return Infinity;
47
- return WINDOW_HOURS[key] / spendable;
48
- }
49
-
50
25
  /** Scoped windows are model-specific; match the one governing this model. */
51
26
  export function scopedWindowFor(quota: QuotaSnapshot | undefined, modelId?: string): QuotaWindow | undefined {
52
27
  const scoped = quota?.scoped;
@@ -59,110 +34,60 @@ export function scopedWindowFor(quota: QuotaSnapshot | undefined, modelId?: stri
59
34
  }) ?? scoped[0];
60
35
  }
61
36
 
62
- /**
63
- * Capacity score. Higher is better, 0 means unusable.
64
- *
65
- * Costs are summed and inverted rather than taking the minimum window, so a
66
- * cheap fast-refilling window still contributes instead of being masked by a
67
- * slow one. Any window at zero yields Infinity and zeroes the account, which
68
- * preserves the hard exclusion rule.
69
- */
70
- export function candidateWeight(candidate: Candidate, family: Family, modelId?: string, reserve = 0): number {
71
- const quota = candidate.quota;
72
- if (!quota) return 0;
73
-
74
- const costs = [
75
- recoveryCost(quota.five_hour, "five_hour", reserve),
76
- recoveryCost(quota.seven_day, "seven_day", reserve),
77
- ];
78
-
79
- // Fable requests are additionally gated by their scoped window.
80
- if (family === "fable") {
81
- const scoped = scopedWindowFor(quota, modelId);
82
- if (scoped) costs.push(recoveryCost(scoped, "scoped", reserve));
83
- }
84
-
85
- const total = costs.reduce((sum, c) => sum + c, 0);
86
- return Number.isFinite(total) && total > 0 ? 1 / total : 0;
87
- }
88
-
89
- export interface Assignment {
90
- accountId: string;
91
- family: Family;
92
- assignedAt: number;
93
- lastSeenAt: number;
94
- }
95
-
96
37
  export interface SelectInput {
97
38
  candidates: Candidate[];
98
39
  family: Family;
99
40
  modelId?: string;
100
41
  mode: RoutingMode;
101
- /** Existing sticky assignment for this session, if any. */
102
- assignment?: Assignment;
103
42
  now?: number;
104
43
  }
105
44
 
106
45
  export interface Selection {
107
46
  candidate: Candidate;
108
- reason: "sticky" | "weighted" | "main-first" | "fallback-first" | "only";
47
+ reason: "quota-aware" | "sequential" | "only";
48
+ }
49
+
50
+ function routingQuota(candidate: Candidate, family: Family, modelId?: string): AccountQuotaState | undefined {
51
+ const quota = candidate.quota;
52
+ if (!quota) return undefined;
53
+ const windows: (QuotaWindow | undefined)[] = [quota.five_hour, quota.seven_day];
54
+ if (family === "fable") windows.push(scopedWindowFor(quota, modelId));
55
+ const measured = windows.filter((window) => window && Number.isFinite(window.remainingPercent));
56
+ if (measured.length === 0) return undefined;
57
+
58
+ const remainingPercent = Math.min(...measured.map((window) => window!.remainingPercent!));
59
+ const resetTimes = measured
60
+ .filter((window) => (window!.remainingPercent ?? 0) <= 0 && window!.resetsAt)
61
+ .map((window) => Date.parse(window!.resetsAt!))
62
+ .filter(Number.isFinite);
63
+ const resetAt = resetTimes.length > 0 ? Math.min(...resetTimes) : undefined;
64
+ return {
65
+ remainingPercent,
66
+ resetAt,
67
+ checkedAt: quota.checkedAt ?? Date.now(),
68
+ blockedUntil: remainingPercent <= 0 ? resetAt ?? Number.POSITIVE_INFINITY : undefined,
69
+ };
109
70
  }
110
71
 
111
- /**
112
- * Picks an account.
113
- *
114
- * `main-first` and `fallback-first` are simple orderings. `sticky-balanced`
115
- * keeps the session where it is while that account is still viable, and
116
- * otherwise takes the highest-weight candidate.
117
- */
72
+ /** Picks an account using the shared sequential or quota-aware policy. */
118
73
  export function selectAccount(input: SelectInput): Selection | undefined {
119
- const usable = input.candidates.filter((c) => c.access);
74
+ const usable = input.candidates.filter((candidate) => candidate.access);
120
75
  if (usable.length === 0) return undefined;
121
76
  if (usable.length === 1) return { candidate: usable[0], reason: "only" };
122
77
 
123
- if (input.mode === "main-first" || input.mode === "fallback-first") {
124
- const ordered = [...usable].sort((a, b) =>
125
- input.mode === "main-first" ? a.order - b.order : b.order - a.order);
126
- const viable = ordered.find((c) => candidateWeight(c, input.family, input.modelId) > 0);
127
- return viable ? { candidate: viable, reason: input.mode } : undefined;
128
- }
129
-
130
- const weighted = usable
131
- .map((candidate) => ({ candidate, weight: candidateWeight(candidate, input.family, input.modelId) }))
132
- .filter((entry) => entry.weight > 0);
133
- if (weighted.length === 0) return undefined;
134
-
135
- // Stay put while the assigned account can still serve: migrating discards
136
- // the prompt cache, which costs more than a slightly better weight gains.
137
- if (input.assignment) {
138
- const held = weighted.find((e) => e.candidate.id === input.assignment!.accountId);
139
- if (held) return { candidate: held.candidate, reason: "sticky" };
140
- }
141
-
142
- weighted.sort((a, b) =>
143
- b.weight - a.weight
144
- || a.candidate.order - b.candidate.order
145
- || a.candidate.id.localeCompare(b.candidate.id));
146
- return { candidate: weighted[0].candidate, reason: "weighted" };
78
+ const selected = selectRoutingCandidate(
79
+ usable.map((candidate) => ({
80
+ id: candidate.id,
81
+ order: candidate.order,
82
+ lastUsed: candidate.lastUsed ?? candidate.account?.lastUsed ?? 0,
83
+ quota: routingQuota(candidate, input.family, input.modelId),
84
+ value: candidate,
85
+ })),
86
+ input.mode,
87
+ input.now,
88
+ );
89
+ return selected ? { candidate: selected.value, reason: input.mode } : undefined;
147
90
  }
148
91
 
149
- /** Seconds until the soonest window that would unblock this model resets. */
150
- export function retryAfterSeconds(candidates: Candidate[], family: Family, modelId?: string, now = Date.now()): number {
151
- const resets: number[] = [];
152
- for (const candidate of candidates) {
153
- const quota = candidate.quota;
154
- if (!quota) continue;
155
- const windows: (QuotaWindow | undefined)[] = [quota.five_hour, quota.seven_day];
156
- if (family === "fable") windows.push(scopedWindowFor(quota, modelId));
157
- for (const w of windows) {
158
- if (!w?.resetsAt) continue;
159
- if ((w.remainingPercent ?? 0) > 0) continue;
160
- const at = Date.parse(w.resetsAt);
161
- if (Number.isFinite(at) && at > now) resets.push(at);
162
- }
163
- }
164
- if (resets.length === 0) return 60;
165
- return Math.max(1, Math.ceil((Math.min(...resets) - now) / 1000));
166
- }
167
92
 
168
93
  export { MAIN_ACCOUNT_ID };
@@ -1,14 +1,13 @@
1
1
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { normalizeRoutingMode, type AccountRoutingMode } from "../accounts/routing.ts";
4
5
 
5
6
  /**
6
7
  * Anthropic account store.
7
8
  *
8
- * Byte-compatible with @cortexkit/anthropic-auth-core so both implementations
9
- * can read the same files and reverting stays possible. The split is theirs and
10
- * is worth preserving: durable identity in `anthropic-auth.json`, live secrets
11
- * and volatile quota in `anthropic-auth-state.json`.
9
+ * Keeps durable identity in `anthropic-auth.json` and live secrets and quota
10
+ * in `anthropic-auth-state.json`.
12
11
  *
13
12
  * anthropic-auth.json id, label, type, enabled, addedAt, routing
14
13
  * anthropic-auth-state.json access, refresh, expires, quota, lastUsed, ...
@@ -54,7 +53,7 @@ export interface Account {
54
53
  apiKey?: string;
55
54
  }
56
55
 
57
- export type RoutingMode = "main-first" | "fallback-first" | "sticky-balanced";
56
+ export type RoutingMode = AccountRoutingMode;
58
57
 
59
58
  export interface Storage {
60
59
  version?: number;
@@ -136,7 +135,7 @@ const pick = <T extends object>(source: any, keys: readonly string[]): T =>
136
135
  Object.fromEntries(keys.filter((k) => source?.[k] !== undefined).map((k) => [k, source[k]])) as T;
137
136
 
138
137
  export function emptyStorage(): Storage {
139
- return { version: 1, accounts: [], routing: { mode: "main-first" } };
138
+ return { version: 1, accounts: [], routing: { mode: "sequential" } };
140
139
  }
141
140
 
142
141
  /**
@@ -208,8 +207,7 @@ export const isOAuthAccount = (a: Account): boolean => a.type === "oauth" && !!a
208
207
  export const isUsable = (a: Account): boolean => a.enabled !== false && isOAuthAccount(a);
209
208
 
210
209
  export function getRoutingMode(storage: Storage | undefined): RoutingMode {
211
- const mode = storage?.routing?.mode;
212
- return mode === "sticky-balanced" || mode === "fallback-first" || mode === "main-first" ? mode : "main-first";
210
+ return normalizeRoutingMode(storage?.routing?.mode as string | undefined);
213
211
  }
214
212
 
215
213
  export function setRoutingMode(mode: RoutingMode, config = configPath()): Storage {
@@ -228,7 +226,7 @@ export function setRoutingMode(mode: RoutingMode, config = configPath()): Storag
228
226
  * the state file is normal.
229
227
  *
230
228
  * This is easy to mistake for stale data and delete. Doing so breaks
231
- * `main-first` routing and orphans every routing assignment targeting `main`.
229
+ * sequential routing and orphans every routing assignment targeting `main`.
232
230
  */
233
231
  export const MAIN_ACCOUNT_ID = "main";
234
232
 
@@ -82,7 +82,15 @@ interface QualityStore {
82
82
  }
83
83
 
84
84
  let cache: QualityStore | undefined;
85
+ let cachedRecords: QualityRecord[] = [];
85
86
  let inFlight: Promise<string[]> | undefined;
87
+ const lookupCache = new Map<string, QualityLookup>();
88
+
89
+ function setCache(next: QualityStore): void {
90
+ cache = next;
91
+ cachedRecords = Object.values(next.records);
92
+ lookupCache.clear();
93
+ }
86
94
 
87
95
  function agentDir(): string {
88
96
  return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
@@ -119,18 +127,18 @@ function loadCache(): void {
119
127
  const parsed = JSON.parse(readFileSync(cachePath(), "utf8")) as QualityStore & { fetchedAt?: number };
120
128
  if (!parsed?.records) return;
121
129
  // Migrate the pre-store format, which only had `fetchedAt`.
122
- cache = {
130
+ setCache({
123
131
  records: parsed.records,
124
132
  checkedAt: parsed.checkedAt ?? parsed.fetchedAt,
125
133
  lastModified: parsed.lastModified,
126
134
  etag: parsed.etag,
127
135
  source: parsed.source ?? "artificial-analysis",
128
- };
136
+ });
129
137
  } catch { /* first run */ }
130
138
  }
131
139
 
132
140
  function persist(next: QualityStore): void {
133
- cache = next;
141
+ setCache(next);
134
142
  try {
135
143
  writeFileSync(cachePath(), JSON.stringify(next), "utf8");
136
144
  } catch { /* cache write is best effort */ }
@@ -289,26 +297,36 @@ export interface QualityLookup {
289
297
  */
290
298
  export function qualityFor(modelId: string, thinkingLevel?: string): QualityLookup {
291
299
  loadCache();
300
+ const key = `${modelId}\0${thinkingLevel ?? ""}`;
301
+ const cached = lookupCache.get(key);
302
+ if (cached) return cached;
303
+
292
304
  const records = cache?.records ?? {};
293
305
  const base = normalizeSlug(modelId);
294
-
295
306
  const levelled = thinkingLevel && thinkingLevel !== "off" ? records[`${base}-${thinkingLevel}`] : undefined;
296
- if (levelled) return { record: levelled, efficiency: costEfficiency(levelled), confidence: "measured", basis: levelled.slug };
297
-
298
307
  const exact = records[base];
299
- if (exact) return { record: exact, efficiency: costEfficiency(exact), confidence: "measured", basis: exact.slug };
300
-
301
- let best: QualityRecord | undefined;
302
- for (const record of Object.values(records)) {
303
- if (!base.includes(record.slug) && !record.slug.includes(base)) continue;
304
- if (!best || record.slug.length > best.slug.length) best = record;
308
+ let result: QualityLookup;
309
+
310
+ if (levelled) {
311
+ result = { record: levelled, efficiency: costEfficiency(levelled), confidence: "measured", basis: levelled.slug };
312
+ } else if (exact) {
313
+ result = { record: exact, efficiency: costEfficiency(exact), confidence: "measured", basis: exact.slug };
314
+ } else {
315
+ let best: QualityRecord | undefined;
316
+ for (const record of cachedRecords) {
317
+ if (!base.includes(record.slug) && !record.slug.includes(base)) continue;
318
+ if (!best || record.slug.length > best.slug.length) best = record;
319
+ }
320
+ result = best
321
+ ? { record: best, efficiency: costEfficiency(best), confidence: "inferred", basis: `nearest match ${best.slug}` }
322
+ : { efficiency: {}, confidence: "unrated", basis: "no Artificial Analysis entry" };
305
323
  }
306
- if (best) return { record: best, efficiency: costEfficiency(best), confidence: "inferred", basis: `nearest match ${best.slug}` };
307
324
 
308
- return { efficiency: {}, confidence: "unrated", basis: "no Artificial Analysis entry" };
325
+ lookupCache.set(key, result);
326
+ return result;
309
327
  }
310
328
 
311
329
  export function allRecords(): QualityRecord[] {
312
330
  loadCache();
313
- return Object.values(cache?.records ?? {});
331
+ return [...cachedRecords];
314
332
  }
@@ -1,5 +1,5 @@
1
1
  import type { QuotaSnapshot } from "../anthropic/store.ts";
2
- import { loadCodexAccounts, saveCodexAccount } from "./store.ts";
2
+ import { MAIN_ACCOUNT_ID, loadCodexAccounts, saveCodexAccount, saveCodexAccounts } from "./store.ts";
3
3
 
4
4
  /**
5
5
  * Codex quota, read from response headers.
@@ -81,7 +81,13 @@ export function applyCodexQuotaHeaders(
81
81
  ): boolean {
82
82
  const quota = parseCodexQuotaHeaders(headers, now);
83
83
  if (!quota) return false;
84
- const account = loadCodexAccounts().accounts.find((a) => a.id === accountId);
84
+ const storage = loadCodexAccounts();
85
+ if (accountId === MAIN_ACCOUNT_ID) {
86
+ storage.main = { ...storage.main, quota };
87
+ saveCodexAccounts(storage);
88
+ return true;
89
+ }
90
+ const account = storage.accounts.find((candidate) => candidate.id === accountId);
85
91
  if (!account) return false;
86
92
  saveCodexAccount({ ...account, quota });
87
93
  return true;
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import type { QuotaSnapshot } from "../anthropic/store.ts";
5
5
 
@@ -26,38 +26,41 @@ export interface CodexAccount {
26
26
  plan?: string;
27
27
  addedAt?: number;
28
28
  lastRefreshedAt?: number;
29
+ lastUsed?: number;
30
+ blockedUntil?: number;
29
31
  quota?: QuotaSnapshot;
30
32
  }
31
33
 
32
34
  export interface CodexStorage {
33
35
  accounts: CodexAccount[];
36
+ main?: { quota?: QuotaSnapshot; lastUsed?: number; blockedUntil?: number };
34
37
  routing?: { mode?: string };
35
38
  }
36
39
 
37
40
  export const MAIN_ACCOUNT_ID = "main";
38
41
 
39
- function storePath(): string {
40
- return join(homedir(), ".pi", "agent", "codex-accounts.json");
42
+ export function codexAccountsPath(): string {
43
+ return process.env.PI_PLUS_CODEX_ACCOUNTS_FILE ?? join(homedir(), ".pi", "agent", "codex-accounts.json");
41
44
  }
42
45
 
43
- export function loadCodexAccounts(path = storePath()): CodexStorage {
46
+ export function loadCodexAccounts(path = codexAccountsPath()): CodexStorage {
44
47
  try {
45
48
  const raw = JSON.parse(readFileSync(path, "utf8"));
46
49
  const accounts = Array.isArray(raw?.accounts) ? raw.accounts : [];
47
- return { accounts, routing: raw?.routing };
50
+ return { accounts, main: raw?.main, routing: raw?.routing };
48
51
  } catch {
49
52
  return { accounts: [] };
50
53
  }
51
54
  }
52
55
 
53
- export function saveCodexAccounts(storage: CodexStorage, path = storePath()): void {
54
- mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
56
+ export function saveCodexAccounts(storage: CodexStorage, path = codexAccountsPath()): void {
57
+ mkdirSync(dirname(path), { recursive: true });
55
58
  // 0600: these are live OAuth credentials.
56
59
  writeFileSync(path, JSON.stringify(storage, null, 2) + "\n", { mode: 0o600 });
57
60
  }
58
61
 
59
62
  /** Inserts or replaces one account, leaving the others untouched. */
60
- export function saveCodexAccount(account: CodexAccount, path = storePath()): void {
63
+ export function saveCodexAccount(account: CodexAccount, path = codexAccountsPath()): void {
61
64
  const storage = loadCodexAccounts(path);
62
65
  const index = storage.accounts.findIndex((a) => a.id === account.id);
63
66
  if (index >= 0) storage.accounts[index] = { ...storage.accounts[index], ...account };
@@ -65,7 +68,7 @@ export function saveCodexAccount(account: CodexAccount, path = storePath()): voi
65
68
  saveCodexAccounts(storage, path);
66
69
  }
67
70
 
68
- export function removeCodexAccount(id: string, path = storePath()): boolean {
71
+ export function removeCodexAccount(id: string, path = codexAccountsPath()): boolean {
69
72
  const storage = loadCodexAccounts(path);
70
73
  const before = storage.accounts.length;
71
74
  storage.accounts = storage.accounts.filter((a) => a.id !== id);
@@ -2,21 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { agentPath, readJson, writeJson } from "./store.ts";
3
3
 
4
4
  /**
5
- * One file for everything pi-plus owns: `~/.pi/agent/pi-plus.json`.
6
- *
7
- * ```json
8
- * {
9
- * "env": { "ARTIFICIAL_ANALYSIS_API_KEY": "aa_…", "AGENT_BOARD_URL": "…" },
10
- * "policy": { "autoApprove": [], "requireApproval": [], "deny": [] },
11
- * "remote": { "injectStatus": true, "defaults": {}, "workers": [] }
12
- * }
13
- * ```
14
- *
15
- * Pi's own `settings.json` and `models.json` are deliberately NOT absorbed:
16
- * pi reads those from fixed paths and folding them in here would break it.
17
- *
18
- * Every legacy file is migrated on first read and left on disk, so downgrading
19
- * keeps working.
5
+ * Unified pi-plus config. Pi's own settings stay separate, while legacy files
6
+ * migrate without being deleted.
20
7
  */
21
8
 
22
9
  export const CONFIG_FILE = "pi-plus.json";
@@ -42,8 +29,8 @@ export interface PiPlusConfig {
42
29
  const DEFAULTS: PiPlusConfig = {
43
30
  env: {},
44
31
  policy: {
45
- autoApprove: ["anthropic/*", "openai-codex/*"],
46
- requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
32
+ autoApprove: ["anthropic/*", "openai-codex/*", "kimi-coding/*", "xai/*"],
33
+ requireApproval: ["openrouter/*", "google/*", "openai/*"],
47
34
  deny: [],
48
35
  },
49
36
  remote: { injectStatus: true, workers: [] },
package/src/core/store.ts CHANGED
@@ -1,13 +1,8 @@
1
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
 
5
- /**
6
- * Small JSON config helper shared by every domain.
7
- *
8
- * Writes go through a temp file + rename so a crash mid-write cannot leave a
9
- * truncated config behind; several of these files hold auth state.
10
- */
5
+ /** Shared JSON storage with atomic writes. */
11
6
 
12
7
  export function agentDir(): string {
13
8
  return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
@@ -25,19 +20,25 @@ export function readJson<T>(path: string, fallback: T): T {
25
20
  }
26
21
  }
27
22
 
28
- /** Best-effort atomic write. Returns false instead of throwing. */
29
- export function writeJson(path: string, value: unknown, pretty = false): boolean {
23
+ /** Best-effort atomic write. */
24
+ export function writeJson(path: string, value: unknown, pretty = false, mode?: number): boolean {
30
25
  const temp = `${path}.${process.pid}.tmp`;
26
+ const content = JSON.stringify(value, undefined, pretty ? 2 : undefined);
27
+ const options = { encoding: "utf8" as const, ...(mode === undefined ? {} : { mode }) };
28
+
31
29
  try {
32
- writeFileSync(temp, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
30
+ mkdirSync(dirname(path), { recursive: true });
31
+ writeFileSync(temp, content, options);
33
32
  renameSync(temp, path);
34
33
  return true;
35
34
  } catch {
36
35
  try {
37
- writeFileSync(path, JSON.stringify(value, undefined, pretty ? 2 : undefined), "utf8");
36
+ writeFileSync(path, content, options);
38
37
  return true;
39
38
  } catch {
40
39
  return false;
40
+ } finally {
41
+ try { rmSync(temp, { force: true }); } catch { /* best effort */ }
41
42
  }
42
43
  }
43
44
  }
@@ -149,7 +149,7 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
149
149
 
150
150
  const initial = await deps.rows();
151
151
  if (initial.length === 0) {
152
- ctx.ui.notify("No providers are configured. Sign in with /account or pi auth.", "info");
152
+ ctx.ui.notify("No providers are configured. Sign in with /accounts or pi auth.", "info");
153
153
  return;
154
154
  }
155
155