@cortexkit/anthropic-auth-core 1.19.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.d.ts +72 -2
- package/dist/accounts.js +390 -26
- package/dist/auth.d.ts +3 -0
- package/dist/auth.js +5 -3
- package/dist/cache1h.d.ts +1 -1
- package/dist/cache1h.js +1 -1
- package/dist/cachekeep.d.ts +22 -2
- package/dist/cachekeep.js +137 -30
- package/dist/cch.d.ts +1 -0
- package/dist/cch.js +6 -1
- package/dist/claude-code.d.ts +2 -1
- package/dist/claude-code.js +3 -3
- package/dist/commands/account.js +1 -1
- package/dist/constants.d.ts +3 -3
- package/dist/dump.d.ts +12 -1
- package/dist/dump.js +166 -22
- package/dist/index.d.ts +2 -0
- package/dist/index.js +26 -24
- package/dist/killswitch.js +1 -1
- package/dist/models.d.ts +22 -6
- package/dist/models.js +16 -0
- package/dist/oauth-profile.js +1 -1
- package/dist/prime.d.ts +219 -0
- package/dist/prime.js +823 -0
- package/dist/quota-manager.d.ts +7 -0
- package/dist/quota-manager.js +24 -15
- package/dist/quotas.d.ts +4 -4
- package/dist/quotas.js +2 -2
- package/dist/relay.d.ts +4 -0
- package/dist/relay.js +29 -18
- package/dist/routing.d.ts +1 -1
- package/dist/routing.js +1 -1
- package/dist/start.d.ts +13 -0
- package/dist/start.js +29 -0
- package/dist/sticky-routing.d.ts +13 -1
- package/dist/sticky-routing.js +58 -2
- package/package.json +1 -1
package/dist/prime.js
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { isOAuthAccount, isPermanentRefreshError, killswitchPassesPolicy, } from './accounts.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
import { CLAUDE_HAIKU_4_5_MODEL_ID, CLAUDE_HAIKU_4_5_PRICING, } from './models.js';
|
|
9
|
+
// Re-export pricing for downstream packages that import it through core's
|
|
10
|
+
// public surface (the test suite verifies exact per-million-token values).
|
|
11
|
+
export { CLAUDE_HAIKU_4_5_PRICING };
|
|
12
|
+
/**
|
|
13
|
+
* `/claude-prime` — opt-in 5h quota window priming. Default OFF.
|
|
14
|
+
*
|
|
15
|
+
* Every OAuth account (main + enabled fallbacks) gets one minimal request
|
|
16
|
+
* fired ~60s after its `five_hour.resetsAt`, exactly once across concurrent
|
|
17
|
+
* OpenCode processes. Catch-up on boot; redundancy guard via fresh quota
|
|
18
|
+
* check; cross-process atomic marker claim via `writeFile(..., flag: 'wx')`.
|
|
19
|
+
*/
|
|
20
|
+
export const CLAUDE_PRIME_COMMAND_NAME = 'claude-prime';
|
|
21
|
+
export const PRIME_TICK_MS = 60_000;
|
|
22
|
+
export const PRIME_DUE_OFFSET_MS = 60_000;
|
|
23
|
+
export const PRIME_MARKER_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
24
|
+
export const PRIME_POST_FIRE_REFRESH_MS = 90_000;
|
|
25
|
+
/** Minimum interval between forced quota checks for an unchanged reset epoch. */
|
|
26
|
+
export const PRIME_CHECK_THROTTLE_MS = 5 * 60_000;
|
|
27
|
+
/**
|
|
28
|
+
* Pure command parser — accepts `''` / `on` / `off` and treats anything else
|
|
29
|
+
* as a usage error. Mirrors `parseFastModeCommandAction` shape so all
|
|
30
|
+
* `claude-*` commands stay uniform.
|
|
31
|
+
*/
|
|
32
|
+
export function parsePrimeCommandAction(input) {
|
|
33
|
+
const normalized = input.trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
if (normalized.length === 0)
|
|
35
|
+
return { type: 'status' };
|
|
36
|
+
if (normalized.length === 1 && normalized[0] === 'on')
|
|
37
|
+
return { type: 'enable' };
|
|
38
|
+
if (normalized.length === 1 && normalized[0] === 'off')
|
|
39
|
+
return { type: 'disable' };
|
|
40
|
+
return { type: 'usage' };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-million-token USD cost projection. Locale-independent math; the display
|
|
44
|
+
* layer is responsible for formatting. Returns 0 for absent counters so
|
|
45
|
+
* pre-fire accounts still get a clean number in the sidebar/dialog.
|
|
46
|
+
*/
|
|
47
|
+
export function estimatePrimeCostUsd(usage) {
|
|
48
|
+
if (!usage)
|
|
49
|
+
return 0;
|
|
50
|
+
return ((usage.inputTokens * CLAUDE_HAIKU_4_5_PRICING.input +
|
|
51
|
+
usage.outputTokens * CLAUDE_HAIKU_4_5_PRICING.output) /
|
|
52
|
+
1_000_000);
|
|
53
|
+
}
|
|
54
|
+
function accountLabel(id, storage, fallbackAccount) {
|
|
55
|
+
if (fallbackAccount?.label?.trim())
|
|
56
|
+
return fallbackAccount.label;
|
|
57
|
+
if (id === 'main')
|
|
58
|
+
return 'main';
|
|
59
|
+
// Stale-storage fallback: the caller passed a non-empty storage so prefer
|
|
60
|
+
// the first matching account label.
|
|
61
|
+
const found = storage?.accounts?.find((a) => a.id === id);
|
|
62
|
+
if (found?.label?.trim())
|
|
63
|
+
return found.label;
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Project per-account prime status. Enumerates a synthetic `main` plus each
|
|
68
|
+
* enabled OAuth fallback. `nextDueAt` is the stored five-hour reset plus
|
|
69
|
+
* `PRIME_DUE_OFFSET_MS`; null when no reset is known. Manager-transient
|
|
70
|
+
* `lastPrimedAt`/`lastResult` overlay the persisted counters so the dialog and
|
|
71
|
+
* sidebar can show "just attempted" without waiting for the next save.
|
|
72
|
+
*/
|
|
73
|
+
export function buildPrimeAccountStatuses(storage, options) {
|
|
74
|
+
const transient = options?.transient;
|
|
75
|
+
const mainStoredQuota = storage?.quota?.mainQuota;
|
|
76
|
+
const mainUsage = storage?.prime?.main;
|
|
77
|
+
const mainStatus = {
|
|
78
|
+
id: 'main',
|
|
79
|
+
label: 'main',
|
|
80
|
+
nextDueAt: primeNextDueAt(mainStoredQuota?.five_hour),
|
|
81
|
+
lastPrimedAt: transient?.get('main')?.lastPrimedAt ?? null,
|
|
82
|
+
lastResult: transient?.get('main')?.lastResult,
|
|
83
|
+
usage: mainUsage,
|
|
84
|
+
estimatedCostUsd: estimatePrimeCostUsd(mainUsage),
|
|
85
|
+
};
|
|
86
|
+
const fallbacks = (storage?.accounts ?? [])
|
|
87
|
+
.filter(isOAuthAccount)
|
|
88
|
+
.filter((account) => account.id !== 'main')
|
|
89
|
+
.filter((account) => account.enabled !== false)
|
|
90
|
+
.map((account) => ({
|
|
91
|
+
id: account.id,
|
|
92
|
+
label: accountLabel(account.id, storage, account),
|
|
93
|
+
nextDueAt: primeNextDueAt(account.quota?.five_hour),
|
|
94
|
+
lastPrimedAt: transient?.get(account.id)?.lastPrimedAt ?? null,
|
|
95
|
+
lastResult: transient?.get(account.id)?.lastResult,
|
|
96
|
+
usage: account.prime,
|
|
97
|
+
estimatedCostUsd: estimatePrimeCostUsd(account.prime),
|
|
98
|
+
}));
|
|
99
|
+
return [mainStatus, ...fallbacks];
|
|
100
|
+
}
|
|
101
|
+
function primeNextDueAt(window) {
|
|
102
|
+
if (!window?.resetsAt)
|
|
103
|
+
return undefined;
|
|
104
|
+
const ms = Date.parse(window.resetsAt);
|
|
105
|
+
if (!Number.isFinite(ms))
|
|
106
|
+
return undefined;
|
|
107
|
+
return ms + PRIME_DUE_OFFSET_MS;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Pure multi-line status summary shared by the OpenCode dialog, the sidebar
|
|
111
|
+
* expanded row, and the Pi display. Title is stable text the dialog uses to
|
|
112
|
+
* anchor and the orchestrator uses to assert RED.
|
|
113
|
+
*/
|
|
114
|
+
export function buildPrimeStatusSummary(input) {
|
|
115
|
+
const header = input.enabled
|
|
116
|
+
? '## Claude Prime Status'
|
|
117
|
+
: '## Claude Prime Disabled';
|
|
118
|
+
const lines = [header, ''];
|
|
119
|
+
lines.push(`Status: ${input.enabled ? 'enabled' : 'disabled'}`);
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('Accounts:');
|
|
122
|
+
for (const account of input.accounts) {
|
|
123
|
+
const usage = account.usage;
|
|
124
|
+
const count = usage?.count ?? 0;
|
|
125
|
+
const due = formatDue(account.nextDueAt);
|
|
126
|
+
const primed = formatPrimed(account.lastPrimedAt, account.lastResult);
|
|
127
|
+
const segments = [`${account.label}`];
|
|
128
|
+
if (due)
|
|
129
|
+
segments.push(`next prime ${due}`);
|
|
130
|
+
if (primed)
|
|
131
|
+
segments.push(primed);
|
|
132
|
+
if (count > 0) {
|
|
133
|
+
segments.push(`${count} ${count === 1 ? 'prime' : 'primes'} \u2248 $${formatUsd(account.estimatedCostUsd ?? 0)}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push(`- ${segments.join(' · ')}`);
|
|
136
|
+
}
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push('Usage: `/claude-prime on`, `/claude-prime off`, or status.');
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
function formatDue(nextDueAt) {
|
|
142
|
+
if (typeof nextDueAt !== 'number')
|
|
143
|
+
return '';
|
|
144
|
+
return new Date(nextDueAt).toLocaleTimeString([], {
|
|
145
|
+
hour: '2-digit',
|
|
146
|
+
minute: '2-digit',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function formatPrimed(lastPrimedAt, lastResult) {
|
|
150
|
+
if (typeof lastPrimedAt !== 'number')
|
|
151
|
+
return '';
|
|
152
|
+
const time = new Date(lastPrimedAt).toLocaleTimeString([], {
|
|
153
|
+
hour: '2-digit',
|
|
154
|
+
minute: '2-digit',
|
|
155
|
+
});
|
|
156
|
+
if (lastResult === 'error')
|
|
157
|
+
return `primed ${time} err`;
|
|
158
|
+
return `primed ${time} \u2713`;
|
|
159
|
+
}
|
|
160
|
+
function formatUsd(value) {
|
|
161
|
+
if (value === 0)
|
|
162
|
+
return '0';
|
|
163
|
+
if (value < 0.0001)
|
|
164
|
+
return value.toExponential(2);
|
|
165
|
+
return value.toFixed(Math.min(6, Math.max(0, 4)));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Pure executor — returns markdown text and an optional `updated` envelope.
|
|
169
|
+
* Effects (persistent toggle, manager start/stop) are the caller's job; Pi
|
|
170
|
+
* uses this same function in display-only mode.
|
|
171
|
+
*/
|
|
172
|
+
export function executePrimeCommand(input) {
|
|
173
|
+
const action = parsePrimeCommandAction(input.argumentsText);
|
|
174
|
+
if (action.type === 'status') {
|
|
175
|
+
return {
|
|
176
|
+
text: buildPrimeStatusSummary({
|
|
177
|
+
enabled: input.enabled,
|
|
178
|
+
accounts: input.accounts,
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (action.type === 'enable') {
|
|
183
|
+
return {
|
|
184
|
+
text: buildPrimeStatusSummary({
|
|
185
|
+
enabled: true,
|
|
186
|
+
accounts: input.accounts,
|
|
187
|
+
}),
|
|
188
|
+
updated: { enabled: true },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (action.type === 'disable') {
|
|
192
|
+
return {
|
|
193
|
+
text: buildPrimeStatusSummary({
|
|
194
|
+
enabled: false,
|
|
195
|
+
accounts: input.accounts,
|
|
196
|
+
}),
|
|
197
|
+
updated: { enabled: false },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
text: [
|
|
202
|
+
'## Claude Prime Usage',
|
|
203
|
+
'',
|
|
204
|
+
'Usage: `/claude-prime`, `/claude-prime on`, or `/claude-prime off`.',
|
|
205
|
+
'',
|
|
206
|
+
buildPrimeStatusSummary({
|
|
207
|
+
enabled: input.enabled,
|
|
208
|
+
accounts: input.accounts,
|
|
209
|
+
}),
|
|
210
|
+
].join('\n'),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const ELIGIBILITY_REASON_LABEL = {
|
|
214
|
+
'not-oauth': 'API-key account',
|
|
215
|
+
disabled: 'disabled',
|
|
216
|
+
'needs-reauth': 'needs re-login',
|
|
217
|
+
killswitch: 'killswitch',
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Eligibility check shared by the manager tick and the dialog preview.
|
|
221
|
+
* Returns a discriminated result so the manager can log a specific reason
|
|
222
|
+
* per the spec's Logging table. Quota policy is evaluated authoritatively on
|
|
223
|
+
* the fresh snapshot immediately before claim.
|
|
224
|
+
*/
|
|
225
|
+
export function primeIsEligible(input) {
|
|
226
|
+
if (!input.isOAuth)
|
|
227
|
+
return { eligible: false, reason: 'not-oauth' };
|
|
228
|
+
if (!input.isEnabled)
|
|
229
|
+
return { eligible: false, reason: 'disabled' };
|
|
230
|
+
if (input.hasPermanentRefreshError)
|
|
231
|
+
return { eligible: false, reason: 'needs-reauth' };
|
|
232
|
+
return { eligible: true };
|
|
233
|
+
}
|
|
234
|
+
export function primeEligibilityReasonLabel(reason) {
|
|
235
|
+
return ELIGIBILITY_REASON_LABEL[reason];
|
|
236
|
+
}
|
|
237
|
+
export const PRIME_REQUEST_BODY = {
|
|
238
|
+
model: CLAUDE_HAIKU_4_5_MODEL_ID,
|
|
239
|
+
max_tokens: 1,
|
|
240
|
+
system: 'Reply with 1 when you receive 0.',
|
|
241
|
+
messages: [{ role: 'user', content: '0' }],
|
|
242
|
+
};
|
|
243
|
+
export function buildPrimeRequestBody() {
|
|
244
|
+
// Copy to keep callers from accidentally mutating the canonical frozen body.
|
|
245
|
+
return JSON.parse(JSON.stringify(PRIME_REQUEST_BODY));
|
|
246
|
+
}
|
|
247
|
+
function defaultMarkerDir() {
|
|
248
|
+
return join(tmpdir(), 'opencode-anthropic-auth', 'prime');
|
|
249
|
+
}
|
|
250
|
+
export function primeStorageFingerprint(storagePath) {
|
|
251
|
+
const absolutePath = resolve(storagePath);
|
|
252
|
+
let canonicalPath;
|
|
253
|
+
try {
|
|
254
|
+
canonicalPath = realpathSync(absolutePath);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
if (error.code !== 'ENOENT')
|
|
258
|
+
throw error;
|
|
259
|
+
canonicalPath = absolutePath;
|
|
260
|
+
}
|
|
261
|
+
return createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12);
|
|
262
|
+
}
|
|
263
|
+
export function primeMarkerNamespaceDir(markerDir, storagePath) {
|
|
264
|
+
// Legacy root markers are intentionally not migrated: an upgrade may send
|
|
265
|
+
// one extra Haiku request per account, whose usage is measured from response
|
|
266
|
+
// accounting, then the namespaced claim self-heals.
|
|
267
|
+
return join(markerDir, primeStorageFingerprint(storagePath));
|
|
268
|
+
}
|
|
269
|
+
export function primeAccountMarkerDir(markerDir, storagePath, accountFingerprint) {
|
|
270
|
+
if (!/^[a-f0-9]{12,}$/i.test(accountFingerprint)) {
|
|
271
|
+
throw new Error('Prime account fingerprint must be a hexadecimal digest');
|
|
272
|
+
}
|
|
273
|
+
return join(primeMarkerNamespaceDir(markerDir, storagePath), accountFingerprint.toLowerCase().slice(0, 12));
|
|
274
|
+
}
|
|
275
|
+
export function primeMarkerPath(markerDir, accountId, resetsAtEpochMs) {
|
|
276
|
+
return join(markerDir, `${encodeURIComponent(accountId)}-${resetsAtEpochMs}`);
|
|
277
|
+
}
|
|
278
|
+
function primeBootstrapMarkerPath(markerDir, accountId) {
|
|
279
|
+
return join(markerDir, `${encodeURIComponent(accountId)}-bootstrap`);
|
|
280
|
+
}
|
|
281
|
+
function primeAccountLabel(id, fallback) {
|
|
282
|
+
if (id === 'main')
|
|
283
|
+
return 'main';
|
|
284
|
+
if (fallback && 'label' in fallback && fallback.label?.trim()) {
|
|
285
|
+
return fallback.label;
|
|
286
|
+
}
|
|
287
|
+
return id;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Build the list of accounts the manager should evaluate EVERY tick.
|
|
291
|
+
* Includes disabled and API-key accounts so the eligibility log fires
|
|
292
|
+
* for them too (the spec Logging table mandates the debug log for
|
|
293
|
+
* every non-eligible case so operators can troubleshoot). Filter for
|
|
294
|
+
* the actual fire decision happens via `primeIsEligible`.
|
|
295
|
+
*/
|
|
296
|
+
function evaluateAccounts(storage) {
|
|
297
|
+
const evaluations = [];
|
|
298
|
+
const mainRefreshError = storage.refresh?.mainLastRefreshError;
|
|
299
|
+
evaluations.push({
|
|
300
|
+
id: 'main',
|
|
301
|
+
label: 'main',
|
|
302
|
+
isOAuth: true,
|
|
303
|
+
isEnabled: true,
|
|
304
|
+
hasPermanentRefreshError: isPermanentRefreshError(mainRefreshError),
|
|
305
|
+
storedQuota: storage.quota?.mainQuota,
|
|
306
|
+
storedFiveHour: storage.quota?.mainQuota?.five_hour,
|
|
307
|
+
});
|
|
308
|
+
for (const account of storage.accounts ?? []) {
|
|
309
|
+
if (account.id === 'main') {
|
|
310
|
+
logger.debug('prime', 'ineligible', {
|
|
311
|
+
account: primeAccountLabel(account.id, account),
|
|
312
|
+
reason: 'reserved-id',
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (!isOAuthAccount(account)) {
|
|
317
|
+
// API-key accounts are surfaced here so the eligibility-skip debug
|
|
318
|
+
// log fires once per tick and an operator can confirm the
|
|
319
|
+
// account won't prime. The check itself is in `primeIsEligible`.
|
|
320
|
+
evaluations.push({
|
|
321
|
+
id: account.id,
|
|
322
|
+
label: primeAccountLabel(account.id, account),
|
|
323
|
+
isOAuth: false,
|
|
324
|
+
isEnabled: account.enabled !== false,
|
|
325
|
+
hasPermanentRefreshError: false,
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
evaluations.push({
|
|
330
|
+
id: account.id,
|
|
331
|
+
label: primeAccountLabel(account.id, account),
|
|
332
|
+
isOAuth: true,
|
|
333
|
+
isEnabled: account.enabled !== false,
|
|
334
|
+
hasPermanentRefreshError: isPermanentRefreshError(account.lastRefreshError),
|
|
335
|
+
storedQuota: account.quota,
|
|
336
|
+
storedFiveHour: account.quota?.five_hour,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return evaluations;
|
|
340
|
+
}
|
|
341
|
+
function storedResetMs(window) {
|
|
342
|
+
if (!window?.resetsAt)
|
|
343
|
+
return undefined;
|
|
344
|
+
const ms = Date.parse(window.resetsAt);
|
|
345
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
346
|
+
}
|
|
347
|
+
export class PrimeManager {
|
|
348
|
+
timer = null;
|
|
349
|
+
postFireRefreshTimers = new Set();
|
|
350
|
+
lastForcedCheck = new Map();
|
|
351
|
+
// Set true by `stop()` so a tick already in flight can short-circuit
|
|
352
|
+
// before claim/send, preventing a stale in-flight cycle from firing
|
|
353
|
+
// after `/claude-prime off` or after a newer plugin invocation has
|
|
354
|
+
// replaced this instance (M3b).
|
|
355
|
+
stopped = false;
|
|
356
|
+
// The registry replaces this dependency bundle on plugin reload so the
|
|
357
|
+
// singleton manager cannot retain closures from an obsolete plugin context.
|
|
358
|
+
options;
|
|
359
|
+
// Per-account transient state from the last ATTEMPT — overlays persisted
|
|
360
|
+
// counters in `stats()` so the sidebar/dialog can show "just attempted"
|
|
361
|
+
// without waiting for the next save. NOT persisted.
|
|
362
|
+
//
|
|
363
|
+
// Crucially, fresh-check skips do NOT touch this map. Only an actual
|
|
364
|
+
// claim+fire attempt mutates it. See `runForAccount` for the invariant
|
|
365
|
+
// (M4): skip is not the same as a successful prime.
|
|
366
|
+
transient = new Map();
|
|
367
|
+
// Latest cumulative counters returned by recordSuccess. Overlays the
|
|
368
|
+
// persisted counters in stats() before the next load persists them.
|
|
369
|
+
counters = new Map();
|
|
370
|
+
// Per-account "window active" observation from the most recent fresh
|
|
371
|
+
// check (M4). Surfaced in the status as the rendered "— window active"
|
|
372
|
+
// text; independent of lastPrimedAt/lastResult.
|
|
373
|
+
windowActive = new Map();
|
|
374
|
+
constructor(options) {
|
|
375
|
+
this.options = options;
|
|
376
|
+
}
|
|
377
|
+
updateOptions(options) {
|
|
378
|
+
this.options = options;
|
|
379
|
+
}
|
|
380
|
+
start() {
|
|
381
|
+
if (this.timer)
|
|
382
|
+
return;
|
|
383
|
+
this.stopped = false;
|
|
384
|
+
logger.trace('prime', 'manager started');
|
|
385
|
+
this.timer = setInterval(() => {
|
|
386
|
+
void this.tick().catch((error) => {
|
|
387
|
+
logger.warn('prime', 'tick failed', {
|
|
388
|
+
error: error instanceof Error ? error.message : String(error),
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}, PRIME_TICK_MS);
|
|
392
|
+
if (typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
393
|
+
;
|
|
394
|
+
this.timer.unref?.();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
stop() {
|
|
398
|
+
const hadTimer = this.timer !== null;
|
|
399
|
+
const hadPostFireRefresh = this.postFireRefreshTimers.size > 0;
|
|
400
|
+
this.stopped = true;
|
|
401
|
+
if (this.timer) {
|
|
402
|
+
clearInterval(this.timer);
|
|
403
|
+
this.timer = null;
|
|
404
|
+
}
|
|
405
|
+
for (const timer of this.postFireRefreshTimers)
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
this.postFireRefreshTimers.clear();
|
|
408
|
+
this.lastForcedCheck.clear();
|
|
409
|
+
if (!hadTimer && !hadPostFireRefresh)
|
|
410
|
+
return;
|
|
411
|
+
logger.trace('prime', 'manager stopped');
|
|
412
|
+
}
|
|
413
|
+
isStopped() {
|
|
414
|
+
return this.stopped;
|
|
415
|
+
}
|
|
416
|
+
async tick() {
|
|
417
|
+
const now = this.options.now?.() ?? Date.now();
|
|
418
|
+
logger.trace('prime', 'tick start', { now });
|
|
419
|
+
await this.sweepMarkers(now);
|
|
420
|
+
if (this.stopped) {
|
|
421
|
+
logger.trace('prime', 'tick: stopped, aborting');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Re-load persisted state at the start of every tick. The previous
|
|
425
|
+
// read may pre-date an `/claude-prime off` from another process or
|
|
426
|
+
// a settings edit (M3b).
|
|
427
|
+
const storage = await this.options.loadStorage();
|
|
428
|
+
if (!storage) {
|
|
429
|
+
logger.trace('prime', 'tick: no storage, skipping');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (storage.prime?.enabled !== true) {
|
|
433
|
+
logger.trace('prime', 'tick: feature disabled, skipping');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const evaluations = evaluateAccounts(storage);
|
|
437
|
+
await Promise.all(evaluations.map((evaluation) => this.runForAccount(evaluation, storage, now)));
|
|
438
|
+
logger.trace('prime', 'tick end');
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Project cumulative per-account status for the sidebar and dialog. Machine
|
|
442
|
+
* values only — formatting belongs in the TUI/sidebar/Pi layer.
|
|
443
|
+
*/
|
|
444
|
+
stats(storage, nowArg) {
|
|
445
|
+
const baseStatuses = buildPrimeAccountStatuses(storage ?? null, {
|
|
446
|
+
now: nowArg,
|
|
447
|
+
transient: this.transient,
|
|
448
|
+
});
|
|
449
|
+
if (this.counters.size === 0)
|
|
450
|
+
return baseStatuses;
|
|
451
|
+
return baseStatuses.map((status) => {
|
|
452
|
+
const overlay = this.counters.get(status.id);
|
|
453
|
+
if (!overlay)
|
|
454
|
+
return status;
|
|
455
|
+
return {
|
|
456
|
+
...status,
|
|
457
|
+
usage: overlay,
|
|
458
|
+
estimatedCostUsd: estimatePrimeCostUsd(overlay),
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/** True if the most recent fresh-check observed the window as already running. */
|
|
463
|
+
isWindowActive(accountId) {
|
|
464
|
+
return this.windowActive.has(accountId);
|
|
465
|
+
}
|
|
466
|
+
async runForAccount(evaluation, storage, now) {
|
|
467
|
+
try {
|
|
468
|
+
if (this.stopped)
|
|
469
|
+
return;
|
|
470
|
+
const eligibility = primeIsEligible({
|
|
471
|
+
storage,
|
|
472
|
+
accountId: evaluation.id,
|
|
473
|
+
isOAuth: evaluation.isOAuth,
|
|
474
|
+
isEnabled: evaluation.isEnabled,
|
|
475
|
+
hasPermanentRefreshError: evaluation.hasPermanentRefreshError,
|
|
476
|
+
quota: evaluation.storedQuota,
|
|
477
|
+
});
|
|
478
|
+
if (!eligibility.eligible) {
|
|
479
|
+
// Per spec Logging table: every stored-state ineligible case (disabled /
|
|
480
|
+
// needs-reauth / API-key) emits a debug log carrying
|
|
481
|
+
// the account's display label and the specific reason. `trace`
|
|
482
|
+
// would hide this from operators troubleshooting "why isn't prime
|
|
483
|
+
// firing for my X account".
|
|
484
|
+
logger.debug('prime', 'ineligible', {
|
|
485
|
+
account: evaluation.label,
|
|
486
|
+
reason: primeEligibilityReasonLabel(eligibility.reason),
|
|
487
|
+
});
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const storedResetEpoch = storedResetMs(evaluation.storedFiveHour);
|
|
491
|
+
if (storedResetEpoch !== undefined &&
|
|
492
|
+
now < storedResetEpoch + PRIME_DUE_OFFSET_MS) {
|
|
493
|
+
// "Not due" is a routine tick outcome, not an operator-visible
|
|
494
|
+
// diagnostic. Per the spec's `trace` row for tick evaluation.
|
|
495
|
+
logger.trace('prime', 'not due', { account: evaluation.label });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const accountFingerprint = await this.options.getAccountFingerprint(evaluation.id);
|
|
499
|
+
if (!accountFingerprint) {
|
|
500
|
+
logger.debug('prime', 'credential identity unavailable', {
|
|
501
|
+
account: evaluation.label,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const claimIdentity = `${evaluation.id}:${accountFingerprint
|
|
506
|
+
.toLowerCase()
|
|
507
|
+
.slice(0, 12)}`;
|
|
508
|
+
const markerDir = this.markerDir(accountFingerprint);
|
|
509
|
+
const bootstrapMarkerPath = primeBootstrapMarkerPath(markerDir, evaluation.id);
|
|
510
|
+
const expectedMarkerPath = storedResetEpoch === undefined
|
|
511
|
+
? bootstrapMarkerPath
|
|
512
|
+
: primeMarkerPath(markerDir, evaluation.id, storedResetEpoch);
|
|
513
|
+
try {
|
|
514
|
+
await stat(expectedMarkerPath);
|
|
515
|
+
// A completed claim makes another forced poll redundant; the normal
|
|
516
|
+
// background refresh will persist the reset when the usage API catches up.
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
if (error.code !== 'ENOENT')
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
if (storedResetEpoch === undefined &&
|
|
524
|
+
(await this.hasAnyClaimMarker(evaluation.id, accountFingerprint))) {
|
|
525
|
+
// The bootstrap sentinel is replaced only after an epoch claim exists.
|
|
526
|
+
// A stale process must observe one side of that transition.
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (this.stopped)
|
|
530
|
+
return;
|
|
531
|
+
// Binding the throttle to the observed epoch preserves the immediate
|
|
532
|
+
// check when a newly passed reset replaces the previous window.
|
|
533
|
+
const lastForcedCheck = this.lastForcedCheck.get(claimIdentity);
|
|
534
|
+
if (lastForcedCheck !== undefined &&
|
|
535
|
+
lastForcedCheck.resetEpoch === storedResetEpoch &&
|
|
536
|
+
now < lastForcedCheck.at + PRIME_CHECK_THROTTLE_MS) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
this.lastForcedCheck.set(claimIdentity, {
|
|
540
|
+
at: now,
|
|
541
|
+
resetEpoch: storedResetEpoch,
|
|
542
|
+
});
|
|
543
|
+
let refreshed;
|
|
544
|
+
try {
|
|
545
|
+
refreshed = await this.options.refreshQuota(evaluation.id);
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
// Quota fresh-check failure: skip this tick without consuming a claim.
|
|
549
|
+
logger.warn('prime', 'refresh failed', {
|
|
550
|
+
account: evaluation.label,
|
|
551
|
+
error: error instanceof Error ? error.message : String(error),
|
|
552
|
+
});
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// Re-check stop + persist opt-in after every await boundary so
|
|
556
|
+
// an `/claude-prime off` (or a newer plugin instance's stop())
|
|
557
|
+
// aborts the cycle BEFORE claim or send (M3b).
|
|
558
|
+
if (this.stopped)
|
|
559
|
+
return;
|
|
560
|
+
const reloaded = await this.options.loadStorage();
|
|
561
|
+
if (reloaded?.prime?.enabled !== true)
|
|
562
|
+
return;
|
|
563
|
+
const fresh = refreshed.quota;
|
|
564
|
+
// Stale fresh-check: the adapter returned a cached snapshot (quota
|
|
565
|
+
// API 429-backoff, another process owns the file lock, or the
|
|
566
|
+
// account was just refreshed by someone else). Skip without
|
|
567
|
+
// claiming — a cached past `resetsAt` would otherwise let us
|
|
568
|
+
// prime against an already-started window. Retry next tick.
|
|
569
|
+
if (!refreshed.fresh) {
|
|
570
|
+
logger.debug('prime', 'stale fresh-check', {
|
|
571
|
+
account: evaluation.label,
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (!killswitchPassesPolicy(fresh, reloaded, evaluation.id, CLAUDE_HAIKU_4_5_MODEL_ID)) {
|
|
576
|
+
logger.debug('prime', 'ineligible', {
|
|
577
|
+
account: evaluation.label,
|
|
578
|
+
reason: primeEligibilityReasonLabel('killswitch'),
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const freshResetEpoch = storedResetMs(fresh?.five_hour);
|
|
583
|
+
if (freshResetEpoch !== undefined && freshResetEpoch > now) {
|
|
584
|
+
// Window already started by a real request. Track the active-
|
|
585
|
+
// window observation separately (M4) so the status renderer can
|
|
586
|
+
// show "— window active" without conflating it with a successful
|
|
587
|
+
// prime. Do NOT touch lastPrimedAt / lastResult.
|
|
588
|
+
logger.debug('prime', 'window active', {
|
|
589
|
+
account: evaluation.label,
|
|
590
|
+
resetsAt: fresh.five_hour?.resetsAt,
|
|
591
|
+
});
|
|
592
|
+
this.windowActive.set(evaluation.id, now);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
this.windowActive.delete(evaluation.id);
|
|
596
|
+
const markerEpoch = freshResetEpoch ?? storedResetEpoch;
|
|
597
|
+
const markerPath = markerEpoch === undefined
|
|
598
|
+
? await this.tryClaimBootstrap(evaluation.id, accountFingerprint)
|
|
599
|
+
: await this.tryClaim(evaluation.id, accountFingerprint, markerEpoch);
|
|
600
|
+
if (!markerPath) {
|
|
601
|
+
logger.debug('prime', 'claim lost', {
|
|
602
|
+
account: evaluation.label,
|
|
603
|
+
marker: `${evaluation.id}-${markerEpoch ?? 'bootstrap'}`,
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const postClaimStorage = await this.options.loadStorage();
|
|
608
|
+
if (this.stopped || postClaimStorage?.prime?.enabled !== true) {
|
|
609
|
+
await rm(markerPath, { force: true });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (markerEpoch !== undefined) {
|
|
613
|
+
// Create the epoch claim before releasing the bootstrap sentinel so
|
|
614
|
+
// concurrent processes never observe an unclaimed transition gap.
|
|
615
|
+
await rm(bootstrapMarkerPath, { force: true });
|
|
616
|
+
if (this.stopped) {
|
|
617
|
+
await rm(markerPath, { force: true });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
await this.fire(evaluation, now, postClaimStorage);
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
logger.warn('prime', 'account evaluation failed', {
|
|
625
|
+
account: evaluation.label,
|
|
626
|
+
error: error instanceof Error ? error.message : String(error),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async tryClaim(accountId, accountFingerprint, resetsAtEpochMs) {
|
|
631
|
+
const dir = this.markerDir(accountFingerprint);
|
|
632
|
+
const markerPath = primeMarkerPath(dir, accountId, resetsAtEpochMs);
|
|
633
|
+
return this.tryClaimPath(dir, markerPath);
|
|
634
|
+
}
|
|
635
|
+
async tryClaimBootstrap(accountId, accountFingerprint) {
|
|
636
|
+
const dir = this.markerDir(accountFingerprint);
|
|
637
|
+
const markerPath = primeBootstrapMarkerPath(dir, accountId);
|
|
638
|
+
return this.tryClaimPath(dir, markerPath);
|
|
639
|
+
}
|
|
640
|
+
async tryClaimPath(dir, markerPath) {
|
|
641
|
+
try {
|
|
642
|
+
await mkdir(dir, { recursive: true });
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// Directory-create failures are propagated by the writeFile attempt below.
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
await writeFile(markerPath, '', { encoding: 'utf8', flag: 'wx' });
|
|
649
|
+
return markerPath;
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
const code = error.code;
|
|
653
|
+
if (code === 'EEXIST')
|
|
654
|
+
return null;
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async hasAnyClaimMarker(accountId, accountFingerprint) {
|
|
659
|
+
const dir = this.markerDir(accountFingerprint);
|
|
660
|
+
const prefix = `${encodeURIComponent(accountId)}-`;
|
|
661
|
+
try {
|
|
662
|
+
return (await readdir(dir)).some((entry) => entry.startsWith(prefix));
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
if (error.code === 'ENOENT')
|
|
666
|
+
return false;
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async fire(evaluation, now, _storage) {
|
|
671
|
+
let result;
|
|
672
|
+
try {
|
|
673
|
+
result = await this.options.sendPrime(evaluation.id);
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
677
|
+
logger.warn('prime', 'prime fire threw', {
|
|
678
|
+
account: evaluation.label,
|
|
679
|
+
error: message,
|
|
680
|
+
});
|
|
681
|
+
this.transient.set(evaluation.id, {
|
|
682
|
+
lastPrimedAt: now,
|
|
683
|
+
lastResult: 'error',
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (this.stopped)
|
|
688
|
+
return;
|
|
689
|
+
if (!result.ok) {
|
|
690
|
+
// Spec Logging table: two distinct warn events.
|
|
691
|
+
// - `prime token refresh failed` — token-refresh failure
|
|
692
|
+
// before the request fires (reason: 'token-refresh').
|
|
693
|
+
// - `prime fire failed` — HTTP error / fetch throw /
|
|
694
|
+
// identity failure during the request (reason: 'send').
|
|
695
|
+
if (result.reason === 'token-refresh') {
|
|
696
|
+
logger.warn('prime', 'prime token refresh failed', {
|
|
697
|
+
account: evaluation.label,
|
|
698
|
+
error: result.error,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
logger.warn('prime', 'prime fire failed', {
|
|
703
|
+
account: evaluation.label,
|
|
704
|
+
status: result.status,
|
|
705
|
+
error: result.error,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
this.transient.set(evaluation.id, {
|
|
709
|
+
lastPrimedAt: now,
|
|
710
|
+
lastResult: 'error',
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const usage = {
|
|
715
|
+
inputTokens: result.usage?.inputTokens,
|
|
716
|
+
outputTokens: result.usage?.outputTokens,
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
const counters = await this.options.recordSuccess(evaluation.id, usage);
|
|
720
|
+
if (this.stopped) {
|
|
721
|
+
// If stopped mid-fire, roll back the transient so a later
|
|
722
|
+
// `stats()` does not report a phantom success.
|
|
723
|
+
this.transient.delete(evaluation.id);
|
|
724
|
+
this.counters.delete(evaluation.id);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
this.counters.set(evaluation.id, counters);
|
|
728
|
+
this.transient.set(evaluation.id, {
|
|
729
|
+
lastPrimedAt: now,
|
|
730
|
+
lastResult: 'ok',
|
|
731
|
+
});
|
|
732
|
+
logger.info('prime', 'prime fired', {
|
|
733
|
+
account: evaluation.label,
|
|
734
|
+
status: result.status,
|
|
735
|
+
ms: result.ms,
|
|
736
|
+
usage: {
|
|
737
|
+
inputTokens: counters.inputTokens,
|
|
738
|
+
outputTokens: counters.outputTokens,
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
this.schedulePostFireRefresh(evaluation);
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
// Persisting counters failed; surface the error but keep the marker so
|
|
745
|
+
// the cycle counts as attempted. Operator can retry next reset epoch.
|
|
746
|
+
logger.warn('prime', 'recordSuccess failed', {
|
|
747
|
+
account: evaluation.label,
|
|
748
|
+
error: error instanceof Error ? error.message : String(error),
|
|
749
|
+
});
|
|
750
|
+
this.transient.set(evaluation.id, {
|
|
751
|
+
lastPrimedAt: now,
|
|
752
|
+
lastResult: 'error',
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
schedulePostFireRefresh(evaluation) {
|
|
757
|
+
// Unified fire-response headers can arm the window instantly on newer
|
|
758
|
+
// trees. This branch predates header harvesting, so a delayed poll captures
|
|
759
|
+
// the reset after the usage API's observed propagation lag.
|
|
760
|
+
const timer = setTimeout(() => {
|
|
761
|
+
this.postFireRefreshTimers.delete(timer);
|
|
762
|
+
if (this.stopped)
|
|
763
|
+
return;
|
|
764
|
+
void (async () => {
|
|
765
|
+
const storage = await this.options.loadStorage();
|
|
766
|
+
if (this.stopped || storage?.prime?.enabled !== true)
|
|
767
|
+
return;
|
|
768
|
+
await this.options.refreshQuota(evaluation.id);
|
|
769
|
+
})().catch((error) => {
|
|
770
|
+
logger.debug('prime', 'post-fire refresh failed', {
|
|
771
|
+
account: evaluation.label,
|
|
772
|
+
error: error instanceof Error ? error.message : String(error),
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
}, PRIME_POST_FIRE_REFRESH_MS);
|
|
776
|
+
this.postFireRefreshTimers.add(timer);
|
|
777
|
+
if (typeof timer === 'object' && 'unref' in timer) {
|
|
778
|
+
;
|
|
779
|
+
timer.unref?.();
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async sweepMarkers(now) {
|
|
783
|
+
const dir = this.storageMarkerDir();
|
|
784
|
+
const swept = await this.sweepMarkerDirectory(dir, now);
|
|
785
|
+
if (swept > 0) {
|
|
786
|
+
logger.trace('prime', 'swept stale markers', { swept });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async sweepMarkerDirectory(dir, now) {
|
|
790
|
+
let entries;
|
|
791
|
+
try {
|
|
792
|
+
entries = await readdir(dir);
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return 0;
|
|
796
|
+
}
|
|
797
|
+
let swept = 0;
|
|
798
|
+
for (const entry of entries) {
|
|
799
|
+
try {
|
|
800
|
+
const path = join(dir, entry);
|
|
801
|
+
const s = await stat(path);
|
|
802
|
+
if (s.isDirectory()) {
|
|
803
|
+
swept += await this.sweepMarkerDirectory(path, now);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (now - s.mtimeMs > PRIME_MARKER_MAX_AGE_MS) {
|
|
807
|
+
await rm(path, { force: true });
|
|
808
|
+
swept += 1;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
// Markers can vanish between readdir and stat; skip silently.
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
return swept;
|
|
816
|
+
}
|
|
817
|
+
storageMarkerDir() {
|
|
818
|
+
return primeMarkerNamespaceDir(this.options.markerDir ?? defaultMarkerDir(), this.options.storagePath);
|
|
819
|
+
}
|
|
820
|
+
markerDir(accountFingerprint) {
|
|
821
|
+
return primeAccountMarkerDir(this.options.markerDir ?? defaultMarkerDir(), this.options.storagePath, accountFingerprint);
|
|
822
|
+
}
|
|
823
|
+
}
|