@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,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, ...
@@ -49,12 +48,15 @@ export interface Account {
49
48
  expires?: number;
50
49
  lastUsed?: number;
51
50
  lastRefreshedAt?: number;
51
+ lastRefreshError?: string;
52
+ /** Stable provider account UUID; survives OAuth token rotation. */
53
+ identity?: string;
52
54
  authLineageId?: string;
53
55
  quota?: QuotaSnapshot;
54
56
  apiKey?: string;
55
57
  }
56
58
 
57
- export type RoutingMode = "main-first" | "fallback-first" | "sticky-balanced";
59
+ export type RoutingMode = AccountRoutingMode;
58
60
 
59
61
  export interface Storage {
60
62
  version?: number;
@@ -69,7 +71,7 @@ export interface Storage {
69
71
  const CONFIG_FIELDS = ["id", "label", "type", "enabled", "addedAt", "baseURL", "authHeader"] as const;
70
72
  /** Fields that belong in the state file. */
71
73
  const STATE_FIELDS = [
72
- "authLineageId", "access", "refresh", "expires", "lastUsed",
74
+ "authLineageId", "identity", "access", "refresh", "expires", "lastUsed",
73
75
  "lastRefreshedAt", "lastRefreshError", "lastQuotaRefreshError", "quota", "profile", "prime", "apiKey",
74
76
  ] as const;
75
77
 
@@ -136,7 +138,7 @@ const pick = <T extends object>(source: any, keys: readonly string[]): T =>
136
138
  Object.fromEntries(keys.filter((k) => source?.[k] !== undefined).map((k) => [k, source[k]])) as T;
137
139
 
138
140
  export function emptyStorage(): Storage {
139
- return { version: 1, accounts: [], routing: { mode: "main-first" } };
141
+ return { version: 1, accounts: [], routing: { mode: "sequential" } };
140
142
  }
141
143
 
142
144
  /**
@@ -208,8 +210,7 @@ export const isOAuthAccount = (a: Account): boolean => a.type === "oauth" && !!a
208
210
  export const isUsable = (a: Account): boolean => a.enabled !== false && isOAuthAccount(a);
209
211
 
210
212
  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";
213
+ return normalizeRoutingMode(storage?.routing?.mode as string | undefined);
213
214
  }
214
215
 
215
216
  export function setRoutingMode(mode: RoutingMode, config = configPath()): Storage {
@@ -228,7 +229,7 @@ export function setRoutingMode(mode: RoutingMode, config = configPath()): Storag
228
229
  * the state file is normal.
229
230
  *
230
231
  * This is easy to mistake for stale data and delete. Doing so breaks
231
- * `main-first` routing and orphans every routing assignment targeting `main`.
232
+ * sequential routing and orphans every routing assignment targeting `main`.
232
233
  */
233
234
  export const MAIN_ACCOUNT_ID = "main";
234
235
 
@@ -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.
@@ -73,16 +73,54 @@ export function parseCodexQuotaHeaders(
73
73
  } as QuotaSnapshot;
74
74
  }
75
75
 
76
- /** Merges a header-derived snapshot into the stored account. */
76
+ /**
77
+ * True when two snapshots carry the same reading. `checkedAt` moves on every
78
+ * response, so comparing it would defeat the whole check.
79
+ */
80
+ function sameQuota(left: QuotaSnapshot | undefined, right: QuotaSnapshot | undefined): boolean {
81
+ if (!left || !right) return false;
82
+ const compare = (snapshot: QuotaSnapshot) => {
83
+ const window = (value: QuotaSnapshot["five_hour"]) =>
84
+ value ? { used: value.usedPercent, resets: value.resetsAt, minutes: (value as any).windowMinutes } : undefined;
85
+ return JSON.stringify({
86
+ five_hour: window(snapshot.five_hour),
87
+ seven_day: window(snapshot.seven_day),
88
+ plan: (snapshot as any).plan,
89
+ });
90
+ };
91
+ return compare(left) === compare(right);
92
+ }
93
+
94
+ /**
95
+ * Merges a header-derived snapshot into the stored account.
96
+ *
97
+ * Runs from `onResponse` on every single reply, so it is deliberately quiet
98
+ * and cheap: unchanged readings never touch the disk, and a failed write is
99
+ * swallowed. Throwing here would reject the awaited `onResponse` inside the
100
+ * provider and kill an in-flight stream — quota telemetry must never do that.
101
+ */
77
102
  export function applyCodexQuotaHeaders(
78
103
  accountId: string,
79
104
  headers: Record<string, unknown> | undefined,
80
105
  now = Date.now(),
81
106
  ): boolean {
82
- const quota = parseCodexQuotaHeaders(headers, now);
83
- if (!quota) return false;
84
- const account = loadCodexAccounts().accounts.find((a) => a.id === accountId);
85
- if (!account) return false;
86
- saveCodexAccount({ ...account, quota });
87
- return true;
107
+ try {
108
+ const quota = parseCodexQuotaHeaders(headers, now);
109
+ if (!quota) return false;
110
+ const storage = loadCodexAccounts();
111
+ if (accountId === MAIN_ACCOUNT_ID) {
112
+ if (sameQuota(storage.main?.quota, quota)) return false;
113
+ storage.main = { ...storage.main, quota };
114
+ saveCodexAccounts(storage);
115
+ return true;
116
+ }
117
+ const account = storage.accounts.find((candidate) => candidate.id === accountId);
118
+ if (!account) return false;
119
+ if (sameQuota(account.quota, quota)) return false;
120
+ saveCodexAccount({ ...account, quota });
121
+ return true;
122
+ } catch {
123
+ // Best effort: a quota write must never break the request that produced it.
124
+ return false;
125
+ }
88
126
  }
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { readJson, writeJson } from "../store.ts";
4
4
  import type { QuotaSnapshot } from "../anthropic/store.ts";
5
5
 
6
6
  /**
@@ -26,38 +26,51 @@ 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 {
44
- try {
45
- const raw = JSON.parse(readFileSync(path, "utf8"));
46
- const accounts = Array.isArray(raw?.accounts) ? raw.accounts : [];
47
- return { accounts, routing: raw?.routing };
48
- } catch {
49
- return { accounts: [] };
50
- }
46
+ export function loadCodexAccounts(path = codexAccountsPath()): CodexStorage {
47
+ const raw = readJson<Partial<CodexStorage>>(path, {});
48
+ return {
49
+ accounts: Array.isArray(raw?.accounts) ? raw.accounts : [],
50
+ main: raw?.main,
51
+ routing: raw?.routing,
52
+ };
51
53
  }
52
54
 
53
- export function saveCodexAccounts(storage: CodexStorage, path = storePath()): void {
54
- mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true });
55
+ /**
56
+ * Atomic write (temp file + rename), matching every other store in the repo.
57
+ *
58
+ * A truncate-in-place `writeFileSync` here let a concurrent reader observe a
59
+ * half-written file; `loadCodexAccounts` swallows the parse error and returns
60
+ * an empty account list, so the next save persisted that emptiness and the
61
+ * user's accounts disappeared. On Windows the same overlap surfaces as
62
+ * EPERM/EBUSY. Callers that mutate credentials want to hear about a failed
63
+ * write, so this still throws — only the per-response quota path swallows it.
64
+ */
65
+ export function saveCodexAccounts(storage: CodexStorage, path = codexAccountsPath()): void {
55
66
  // 0600: these are live OAuth credentials.
56
- writeFileSync(path, JSON.stringify(storage, null, 2) + "\n", { mode: 0o600 });
67
+ if (!writeJson(path, storage, true, 0o600)) {
68
+ throw new Error(`Could not write Codex accounts to ${path}`);
69
+ }
57
70
  }
58
71
 
59
72
  /** Inserts or replaces one account, leaving the others untouched. */
60
- export function saveCodexAccount(account: CodexAccount, path = storePath()): void {
73
+ export function saveCodexAccount(account: CodexAccount, path = codexAccountsPath()): void {
61
74
  const storage = loadCodexAccounts(path);
62
75
  const index = storage.accounts.findIndex((a) => a.id === account.id);
63
76
  if (index >= 0) storage.accounts[index] = { ...storage.accounts[index], ...account };
@@ -65,7 +78,7 @@ export function saveCodexAccount(account: CodexAccount, path = storePath()): voi
65
78
  saveCodexAccounts(storage, path);
66
79
  }
67
80
 
68
- export function removeCodexAccount(id: string, path = storePath()): boolean {
81
+ export function removeCodexAccount(id: string, path = codexAccountsPath()): boolean {
69
82
  const storage = loadCodexAccounts(path);
70
83
  const before = storage.accounts.length;
71
84
  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";
@@ -33,20 +20,28 @@ export interface RemoteSection {
33
20
  workers: Record<string, unknown>[];
34
21
  }
35
22
 
23
+ export type BetterCompactMode = "off" | "on" | "jev";
24
+
25
+ export interface CompactSection {
26
+ better: BetterCompactMode;
27
+ }
28
+
36
29
  export interface PiPlusConfig {
37
30
  env: Record<string, string>;
38
31
  policy: PolicySection;
39
32
  remote: RemoteSection;
33
+ compact: CompactSection;
40
34
  }
41
35
 
42
36
  const DEFAULTS: PiPlusConfig = {
43
37
  env: {},
44
38
  policy: {
45
- autoApprove: ["anthropic/*", "openai-codex/*"],
46
- requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
39
+ autoApprove: ["anthropic/*", "openai-codex/*", "kimi-coding/*", "xai/*"],
40
+ requireApproval: ["openrouter/*", "google/*", "openai/*"],
47
41
  deny: [],
48
42
  },
49
43
  remote: { injectStatus: true, workers: [] },
44
+ compact: { better: "off" },
50
45
  };
51
46
 
52
47
  /** Legacy file -> section, applied only when that section is still absent. */
@@ -116,6 +111,7 @@ export function configPath(): string {
116
111
  }
117
112
 
118
113
  function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
114
+ const better = raw?.compact?.better;
119
115
  return {
120
116
  env: raw?.env && typeof raw.env === "object" ? { ...raw.env } : {},
121
117
  policy: {
@@ -130,6 +126,9 @@ function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
130
126
  defaults: raw?.remote?.defaults,
131
127
  workers: Array.isArray(raw?.remote?.workers) ? raw.remote.workers : [],
132
128
  },
129
+ compact: {
130
+ better: better === "on" || better === "jev" ? better : "off",
131
+ },
133
132
  };
134
133
  }
135
134
 
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
  }