sidekick-shared 0.18.5 → 0.19.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/README.md CHANGED
@@ -33,11 +33,11 @@ npm install sidekick-shared
33
33
  | **Provider Status** | API health checking via status.claude.com and status.openai.com (indicator, components, incidents) |
34
34
  | **Schemas** | Zod schemas for runtime JSONL event validation (`sessionEventSchema`, `messageUsageSchema`, `sessionMessageSchema`) |
35
35
  | **Extractors** | Pure functions for single-event processing: `extractTokenUsage()`, `extractToolCall()` (top-level `tool_use`), `extractToolCalls()` (assistant content blocks) |
36
- | **Model Info & Pricing** | Model family parsing (Anthropic / OpenAI / Google, including legacy `claude-3-opus-…` and `claude-3-5-sonnet-…` IDs), context-window lookup (including Opus 4.7 / Sonnet 4.7 1M and GPT-5.x variants), pricing tables with optional LiteLLM hydration, null-aware cost (`calculateCost()`), provenance-preserving cost (`calculateCostWithProvenance()`, `mergeCostSources()`), and display helpers (`shortModelName()`, `getModelDisplayInfo()`, `compareModelIds()`, `sortModelIds()`, `formatCost()`) |
36
+ | **Model Info & Pricing** | Model family parsing (Anthropic / OpenAI / Google, including legacy `claude-3-opus-…` and `claude-3-5-sonnet-…` IDs), context-window lookup (including Fable 5 / Opus 4.8 / Opus 4.7 / Sonnet 4.7 1M and GPT-5.x variants), pricing tables with optional LiteLLM hydration, null-aware cost (`calculateCost()`), provenance-preserving cost (`calculateCostWithProvenance()`, `mergeCostSources()`), and display helpers (`shortModelName()`, `getModelDisplayInfo()`, `compareModelIds()`, `sortModelIds()`, `formatCost()`) |
37
37
  | **Quota Polling** | `QuotaPoller` class with exponential backoff, active/idle intervals, and cached fallback |
38
38
  | **Multi-Provider Quota** | `MultiProviderQuotaService` orchestrates Claude polling + peak-hours + account labels + Codex quota updates behind one typed `{ claude?, codex? }` event stream. `CodexQuotaWatcher` watches the active Codex rollout for live rate limits with snapshot fallback |
39
39
  | **Accounts** | Multi-provider account registry (v2) with per-provider active account, save/switch/remove, v1 migration, `ensureDefaultAccounts()` for first-run bootstrap of the active system Claude/Codex credentials as a "Default" saved account, and `getActiveAccountStatus()` for a single-pass active-account read across providers |
40
- | **Codex Profiles** | Codex account lifecycle — prepare, finalize, switch, remove — with isolated `CODEX_HOME` directories and multi-home monitoring support |
40
+ | **Codex Profiles** | Codex account lifecycle — prepare, finalize, switch, remove — switching atomically swaps the profile's backed-up credentials into the system `~/.codex/auth.json`, with rotated-token staleness protection, one-time dual-home migration, and legacy multi-home session monitoring |
41
41
  | **Quota Snapshots** | Persistent quota caching per provider/account for offline fallback |
42
42
  | **Phrases** | Curated humorous phrases for loading/idle states, available as a flat `ALL_PHRASES` array or grouped via `PHRASE_CATEGORIES` for category-aware UI |
43
43
 
@@ -22,6 +22,7 @@ export interface ActiveAccountInfo {
22
22
  export interface AccountManagerResult {
23
23
  success: boolean;
24
24
  error?: string;
25
+ warning?: string;
25
26
  needsLogin?: boolean;
26
27
  profileId?: string;
27
28
  codexHome?: string;
@@ -14,6 +14,7 @@ export declare function getActiveCodexAccount(): SavedAccountProfile | null;
14
14
  export declare function resolveSidekickCodexHome(): string;
15
15
  export declare function getCodexExecutionEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
16
16
  export declare function prepareCodexAccount(label: string): CodexAccountManagerResult;
17
- export declare function finalizeCodexAccount(profileId: string): AccountManagerResult;
18
- export declare function switchToCodexAccount(profileId: string): AccountManagerResult;
17
+ export declare function finalizeCodexAccount(profileId: string): CodexAccountManagerResult;
18
+ export declare function switchToCodexAccount(profileId: string): CodexAccountManagerResult;
19
+ export declare function reconcileCodexAuthState(): void;
19
20
  export declare function removeCodexAccount(profileId: string): AccountManagerResult;
@@ -44,6 +44,7 @@ exports.getCodexExecutionEnv = getCodexExecutionEnv;
44
44
  exports.prepareCodexAccount = prepareCodexAccount;
45
45
  exports.finalizeCodexAccount = finalizeCodexAccount;
46
46
  exports.switchToCodexAccount = switchToCodexAccount;
47
+ exports.reconcileCodexAuthState = reconcileCodexAuthState;
47
48
  exports.removeCodexAccount = removeCodexAccount;
48
49
  const fs = __importStar(require("fs"));
49
50
  const os = __importStar(require("os"));
@@ -51,6 +52,9 @@ const path = __importStar(require("path"));
51
52
  const child_process_1 = require("child_process");
52
53
  const crypto_1 = require("crypto");
53
54
  const accountRegistry_1 = require("./accountRegistry");
55
+ // Codex refreshes OAuth tokens at most every 8 days; a stored refresh token
56
+ // older than that may already be rejected by the auth server.
57
+ const STALE_AUTH_THRESHOLD_MS = 8 * 24 * 60 * 60 * 1000;
54
58
  function getDefaultSystemCodexHome() {
55
59
  return path.join(os.homedir(), '.codex');
56
60
  }
@@ -77,12 +81,15 @@ function getCodexMonitoringHomes() {
77
81
  const explicitHome = getExplicitCodexHome();
78
82
  if (explicitHome)
79
83
  return [explicitHome];
80
- const homes = [];
81
- const active = getActiveCodexAccount();
82
- if (active) {
83
- homes.push(getCodexProfileHome(active.id));
84
+ // The system home is the single live home; profile homes only matter for
85
+ // sessions recorded back when they doubled as live CODEX_HOMEs.
86
+ const homes = [getDefaultSystemCodexHome()];
87
+ for (const profile of listCodexAccounts()) {
88
+ const profileHome = getCodexProfileHome(profile.id);
89
+ if (fs.existsSync(path.join(profileHome, 'sessions'))) {
90
+ homes.push(profileHome);
91
+ }
84
92
  }
85
- homes.push(getDefaultSystemCodexHome());
86
93
  return dedupePaths(homes);
87
94
  }
88
95
  function getCodexProfilesDir() {
@@ -104,8 +111,43 @@ function atomicWriteJson(filePath, data, mode = 0o600) {
104
111
  const tmp = filePath + '.tmp';
105
112
  const json = JSON.stringify(data, null, 2);
106
113
  JSON.parse(json);
107
- fs.writeFileSync(tmp, json, { encoding: 'utf8', mode });
108
- fs.renameSync(tmp, filePath);
114
+ try {
115
+ fs.writeFileSync(tmp, json, { encoding: 'utf8', mode });
116
+ fs.renameSync(tmp, filePath);
117
+ }
118
+ catch (err) {
119
+ try {
120
+ fs.unlinkSync(tmp);
121
+ }
122
+ catch { /* nothing to clean up */ }
123
+ throw err;
124
+ }
125
+ }
126
+ // auth.json must be copied byte-for-byte: re-serializing would drop fields
127
+ // added by newer codex versions, and the rotated refresh token inside is
128
+ // only valid in its freshest form.
129
+ function atomicWriteFile(filePath, content, mode = 0o600) {
130
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
131
+ const tmp = filePath + '.tmp';
132
+ try {
133
+ fs.writeFileSync(tmp, content, { encoding: 'utf8', mode });
134
+ fs.renameSync(tmp, filePath);
135
+ }
136
+ catch (err) {
137
+ try {
138
+ fs.unlinkSync(tmp);
139
+ }
140
+ catch { /* nothing to clean up */ }
141
+ throw err;
142
+ }
143
+ }
144
+ function readFileOrNull(filePath) {
145
+ try {
146
+ return fs.readFileSync(filePath, 'utf8');
147
+ }
148
+ catch {
149
+ return null;
150
+ }
109
151
  }
110
152
  function readPendingProfile(profileId) {
111
153
  try {
@@ -146,40 +188,65 @@ function parseJwtPayload(jwt) {
146
188
  return null;
147
189
  }
148
190
  }
149
- function readMetadataFromAuthJson(codexHome) {
150
- const authPath = path.join(codexHome, 'auth.json');
151
- if (!fs.existsSync(authPath))
152
- return {};
191
+ function parseAuthJson(raw) {
192
+ if (!raw)
193
+ return null;
153
194
  try {
154
- const parsed = JSON.parse(fs.readFileSync(authPath, 'utf8'));
155
- const idToken = parsed.tokens?.id_token;
156
- const claims = idToken ? parseJwtPayload(idToken) : null;
157
- const profileClaims = claims?.['https://api.openai.com/profile'];
158
- const authClaims = claims?.['https://api.openai.com/auth'];
159
- const email = typeof claims?.email === 'string'
160
- ? claims.email
161
- : typeof profileClaims?.email === 'string'
162
- ? profileClaims.email
163
- : undefined;
164
- const workspaceId = typeof authClaims?.chatgpt_account_id === 'string'
165
- ? authClaims.chatgpt_account_id
166
- : parsed.tokens?.account_id;
167
- const planType = typeof authClaims?.chatgpt_plan_type === 'string'
168
- ? authClaims.chatgpt_plan_type
169
- : undefined;
170
- const authMode = parsed.OPENAI_API_KEY || parsed.auth_mode === 'api_key'
171
- ? 'api-key'
172
- : 'chatgpt';
173
- return {
174
- email,
175
- workspaceId,
176
- planType,
177
- authMode,
178
- };
195
+ return JSON.parse(raw);
179
196
  }
180
197
  catch {
181
- return {};
198
+ return null;
199
+ }
200
+ }
201
+ function readAuthIdentityFromRaw(raw) {
202
+ const parsed = parseAuthJson(raw);
203
+ if (!parsed)
204
+ return null;
205
+ const idToken = parsed.tokens?.id_token;
206
+ const claims = idToken ? parseJwtPayload(idToken) : null;
207
+ const profileClaims = claims?.['https://api.openai.com/profile'];
208
+ const authClaims = claims?.['https://api.openai.com/auth'];
209
+ const email = typeof claims?.email === 'string'
210
+ ? claims.email
211
+ : typeof profileClaims?.email === 'string'
212
+ ? profileClaims.email
213
+ : undefined;
214
+ const workspaceId = typeof authClaims?.chatgpt_account_id === 'string'
215
+ ? authClaims.chatgpt_account_id
216
+ : parsed.tokens?.account_id;
217
+ const planType = typeof authClaims?.chatgpt_plan_type === 'string'
218
+ ? authClaims.chatgpt_plan_type
219
+ : undefined;
220
+ const authMode = parsed.OPENAI_API_KEY || parsed.auth_mode === 'api_key'
221
+ ? 'api-key'
222
+ : 'chatgpt';
223
+ return { email, workspaceId, planType, authMode };
224
+ }
225
+ function readLastRefresh(raw, fallbackPath) {
226
+ const parsed = parseAuthJson(raw);
227
+ if (parsed?.last_refresh) {
228
+ const ts = Date.parse(parsed.last_refresh);
229
+ if (!Number.isNaN(ts))
230
+ return ts;
182
231
  }
232
+ if (fallbackPath) {
233
+ try {
234
+ return fs.statSync(fallbackPath).mtimeMs;
235
+ }
236
+ catch { /* fall through */ }
237
+ }
238
+ return null;
239
+ }
240
+ function readMetadataFromAuthJson(codexHome) {
241
+ const identity = readAuthIdentityFromRaw(readFileOrNull(path.join(codexHome, 'auth.json')));
242
+ if (!identity)
243
+ return {};
244
+ return {
245
+ email: identity.email,
246
+ workspaceId: identity.workspaceId,
247
+ planType: identity.planType,
248
+ authMode: identity.authMode,
249
+ };
183
250
  }
184
251
  function readMetadataFromLegacyCredentials(codexHome) {
185
252
  const legacyPath = path.join(codexHome, '.credentials.json');
@@ -219,6 +286,16 @@ function getCodexLoginStatus(codexHome) {
219
286
  }
220
287
  return { loggedIn: false };
221
288
  }
289
+ function detectRunningCodexProcess() {
290
+ if (process.platform === 'win32')
291
+ return false;
292
+ try {
293
+ return (0, child_process_1.spawnSync)('pgrep', ['-x', 'codex'], { encoding: 'utf8' }).status === 0;
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ }
222
299
  function readCodexAccountMetadata(codexHome) {
223
300
  const fromAuth = readMetadataFromAuthJson(codexHome);
224
301
  if (fromAuth.email || fromAuth.workspaceId || fromAuth.planType || fromAuth.authMode) {
@@ -256,15 +333,8 @@ function getActiveCodexAccount() {
256
333
  return (0, accountRegistry_1.getActiveSavedAccount)('codex');
257
334
  }
258
335
  function resolveSidekickCodexHome() {
259
- const explicitHome = process.env.CODEX_HOME;
260
- if (explicitHome)
261
- return explicitHome;
262
- const active = getActiveCodexAccount();
263
- if (active) {
264
- const managedHome = getCodexProfileHome(active.id);
265
- if (fs.existsSync(managedHome))
266
- return managedHome;
267
- }
336
+ // Account switching swaps auth.json inside the system home, so the system
337
+ // home (or an explicit CODEX_HOME) is always the single live home.
268
338
  return getSystemCodexHome();
269
339
  }
270
340
  function getCodexExecutionEnv(baseEnv = process.env) {
@@ -318,24 +388,284 @@ function finalizeCodexAccount(profileId) {
318
388
  return { success: false, error: 'Codex profile is not authenticated yet.' };
319
389
  }
320
390
  const metadata = readCodexAccountMetadata(codexHome);
321
- (0, accountRegistry_1.upsertSavedAccountProfile)({
391
+ const profile = {
322
392
  id: profileId,
323
393
  providerId: 'codex',
324
394
  label: pending.label,
325
395
  email: metadata.email,
326
396
  addedAt: pending.addedAt,
327
397
  metadata,
328
- });
329
- (0, accountRegistry_1.setActiveSavedAccount)('codex', profileId);
330
- return { success: true };
398
+ };
399
+ (0, accountRegistry_1.upsertSavedAccountProfile)(profile);
400
+ const hasCredentialFiles = fs.existsSync(path.join(codexHome, 'auth.json')) ||
401
+ fs.existsSync(path.join(codexHome, '.credentials.json'));
402
+ if (!hasCredentialFiles) {
403
+ // Authenticated via the OS keyring — there are no credential files to
404
+ // swap, so the registry pointer is all we can update.
405
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', profileId);
406
+ return {
407
+ success: true,
408
+ warning: 'Codex stores credentials in the OS keyring; sidekick cannot swap them per account, so `codex` keeps using the keyring credentials.',
409
+ };
410
+ }
411
+ return performCodexAuthSwap(profile);
412
+ }
413
+ function getCodexStashDir() {
414
+ return path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', 'stash');
415
+ }
416
+ function stashLiveCodexAuth(liveAuthRaw, liveLegacyRaw) {
417
+ try {
418
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
419
+ let stashPath = null;
420
+ if (liveAuthRaw) {
421
+ stashPath = path.join(getCodexStashDir(), `auth-${stamp}.json`);
422
+ atomicWriteFile(stashPath, liveAuthRaw);
423
+ }
424
+ if (liveLegacyRaw) {
425
+ const legacyStashPath = path.join(getCodexStashDir(), `credentials-${stamp}.json`);
426
+ atomicWriteFile(legacyStashPath, liveLegacyRaw);
427
+ stashPath = stashPath ?? legacyStashPath;
428
+ }
429
+ return stashPath;
430
+ }
431
+ catch {
432
+ return null;
433
+ }
434
+ }
435
+ function findProfileForIdentity(identity) {
436
+ const profiles = listCodexAccounts();
437
+ if (identity?.workspaceId) {
438
+ const byWorkspace = profiles.find(profile => profile.metadata?.workspaceId === identity.workspaceId);
439
+ if (byWorkspace)
440
+ return byWorkspace;
441
+ }
442
+ if (identity?.email) {
443
+ const byEmail = profiles.find(profile => (profile.email ?? profile.metadata?.email) === identity.email);
444
+ if (byEmail)
445
+ return byEmail;
446
+ }
447
+ if (!identity?.workspaceId && !identity?.email) {
448
+ // API-key auth or unparseable tokens carry no identity; assume the live
449
+ // file belongs to whichever account the registry says is active.
450
+ return getActiveCodexAccount();
451
+ }
452
+ return null;
453
+ }
454
+ // Codex rotates the refresh token whenever it refreshes auth.json, so the
455
+ // live file is always the freshest copy of its account. Before replacing it,
456
+ // preserve it in the matching profile's backup — or stash it if it belongs to
457
+ // no saved account. Best-effort: never throws.
458
+ function syncBackLiveCodexAuth(liveAuthRaw, liveLegacyRaw) {
459
+ if (!liveAuthRaw && !liveLegacyRaw)
460
+ return {};
461
+ try {
462
+ const identity = readAuthIdentityFromRaw(liveAuthRaw);
463
+ const profile = findProfileForIdentity(identity);
464
+ if (!profile) {
465
+ const stashPath = stashLiveCodexAuth(liveAuthRaw, liveLegacyRaw);
466
+ return {
467
+ stashPath: stashPath ?? undefined,
468
+ warning: stashPath
469
+ ? `Live Codex credentials did not match any saved account; stashed at ${stashPath}.`
470
+ : 'Live Codex credentials did not match any saved account and could not be stashed.',
471
+ };
472
+ }
473
+ const profileHome = getCodexProfileHome(profile.id);
474
+ if (liveAuthRaw)
475
+ atomicWriteFile(path.join(profileHome, 'auth.json'), liveAuthRaw);
476
+ if (liveLegacyRaw)
477
+ atomicWriteFile(path.join(profileHome, '.credentials.json'), liveLegacyRaw);
478
+ try {
479
+ const metadata = readCodexAccountMetadata(profileHome);
480
+ (0, accountRegistry_1.upsertSavedAccountProfile)({
481
+ ...profile,
482
+ email: metadata.email ?? profile.email,
483
+ metadata: { ...profile.metadata, ...metadata },
484
+ });
485
+ }
486
+ catch { /* metadata refresh is best-effort */ }
487
+ return { syncedProfileId: profile.id };
488
+ }
489
+ catch (err) {
490
+ return { warning: `Could not back up live Codex credentials: ${err}` };
491
+ }
492
+ }
493
+ function performCodexAuthSwap(target) {
494
+ const systemHome = getSystemCodexHome();
495
+ const liveAuthPath = path.join(systemHome, 'auth.json');
496
+ const liveLegacyPath = path.join(systemHome, '.credentials.json');
497
+ const liveAuthRaw = readFileOrNull(liveAuthPath);
498
+ const liveLegacyRaw = readFileOrNull(liveLegacyPath);
499
+ if (!liveAuthRaw && !liveLegacyRaw && getCodexLoginStatus(systemHome).loggedIn) {
500
+ return {
501
+ success: false,
502
+ error: 'Codex stores credentials in the OS keyring; file-based account switching is not supported. Set `cli_auth_credentials_store = "file"` in ~/.codex/config.toml and run `codex login` again.',
503
+ };
504
+ }
505
+ const profileHome = getCodexProfileHome(target.id);
506
+ const targetAuthPath = path.join(profileHome, 'auth.json');
507
+ const targetAuthRaw = readFileOrNull(targetAuthPath);
508
+ const targetLegacyRaw = readFileOrNull(path.join(profileHome, '.credentials.json'));
509
+ const targetName = target.label ?? target.email ?? target.id;
510
+ if (!targetAuthRaw && !targetLegacyRaw) {
511
+ return { success: false, error: `No stored credentials for "${targetName}". Remove and re-add this account.` };
512
+ }
513
+ if (targetAuthRaw && !parseAuthJson(targetAuthRaw)) {
514
+ return { success: false, error: `Stored credentials for "${targetName}" are corrupted. Remove and re-add this account.` };
515
+ }
516
+ const warnings = [];
517
+ if (detectRunningCodexProcess()) {
518
+ warnings.push('A codex process appears to be running; restart codex sessions so they pick up the switched account.');
519
+ }
520
+ // If the live file already belongs to the target account it is the freshest
521
+ // copy (rotated refresh token included) — never replace it with a staler
522
+ // backup, which would permanently invalidate the login. Just refresh the
523
+ // backup and the registry pointer.
524
+ const liveIdentity = readAuthIdentityFromRaw(liveAuthRaw);
525
+ const targetIdentity = readAuthIdentityFromRaw(targetAuthRaw);
526
+ const targetWorkspaceId = target.metadata?.workspaceId ?? targetIdentity?.workspaceId;
527
+ const targetEmail = target.email ?? target.metadata?.email ?? targetIdentity?.email;
528
+ const liveMatchesTarget = Boolean((liveIdentity?.workspaceId && targetWorkspaceId && liveIdentity.workspaceId === targetWorkspaceId) ||
529
+ (liveIdentity?.email && targetEmail && liveIdentity.email === targetEmail) ||
530
+ (liveAuthRaw !== null && liveAuthRaw === targetAuthRaw) ||
531
+ (!liveAuthRaw && !targetAuthRaw && liveLegacyRaw !== null && liveLegacyRaw === targetLegacyRaw));
532
+ if (liveMatchesTarget) {
533
+ try {
534
+ if (liveAuthRaw)
535
+ atomicWriteFile(targetAuthPath, liveAuthRaw);
536
+ if (liveLegacyRaw)
537
+ atomicWriteFile(path.join(profileHome, '.credentials.json'), liveLegacyRaw);
538
+ const metadata = readCodexAccountMetadata(profileHome);
539
+ (0, accountRegistry_1.upsertSavedAccountProfile)({
540
+ ...target,
541
+ email: metadata.email ?? target.email,
542
+ metadata: { ...target.metadata, ...metadata },
543
+ });
544
+ }
545
+ catch { /* backup refresh is best-effort */ }
546
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
547
+ return { success: true, warning: warnings.length ? warnings.join(' ') : undefined };
548
+ }
549
+ const targetLastRefresh = readLastRefresh(targetAuthRaw, targetAuthPath);
550
+ if (targetLastRefresh !== null && Date.now() - targetLastRefresh > STALE_AUTH_THRESHOLD_MS) {
551
+ warnings.push(`Stored credentials for "${targetName}" have not been refreshed in over 8 days; codex may ask you to log in again.`);
552
+ }
553
+ const syncBack = syncBackLiveCodexAuth(liveAuthRaw, liveLegacyRaw);
554
+ if (syncBack.warning)
555
+ warnings.push(syncBack.warning);
556
+ const restoreLiveFiles = () => {
557
+ try {
558
+ if (liveAuthRaw)
559
+ atomicWriteFile(liveAuthPath, liveAuthRaw);
560
+ else
561
+ fs.rmSync(liveAuthPath, { force: true });
562
+ if (liveLegacyRaw)
563
+ atomicWriteFile(liveLegacyPath, liveLegacyRaw);
564
+ else
565
+ fs.rmSync(liveLegacyPath, { force: true });
566
+ }
567
+ catch { /* rollback is best-effort */ }
568
+ };
569
+ try {
570
+ if (targetAuthRaw) {
571
+ atomicWriteFile(liveAuthPath, targetAuthRaw);
572
+ if (targetLegacyRaw)
573
+ atomicWriteFile(liveLegacyPath, targetLegacyRaw);
574
+ else if (liveLegacyRaw)
575
+ fs.rmSync(liveLegacyPath, { force: true });
576
+ }
577
+ else {
578
+ atomicWriteFile(liveLegacyPath, targetLegacyRaw);
579
+ if (liveAuthRaw)
580
+ fs.rmSync(liveAuthPath, { force: true });
581
+ }
582
+ }
583
+ catch (err) {
584
+ restoreLiveFiles();
585
+ return { success: false, error: `Failed to write Codex credentials: ${err}` };
586
+ }
587
+ try {
588
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', target.id);
589
+ }
590
+ catch (err) {
591
+ restoreLiveFiles();
592
+ return { success: false, error: `Failed to update account registry: ${err}` };
593
+ }
594
+ return { success: true, warning: warnings.length ? warnings.join(' ') : undefined };
331
595
  }
332
596
  function switchToCodexAccount(profileId) {
333
597
  const target = listCodexAccounts().find(account => account.id === profileId);
334
598
  if (!target) {
335
599
  return { success: false, error: `Codex account ${profileId} not found.` };
336
600
  }
337
- (0, accountRegistry_1.setActiveSavedAccount)('codex', profileId);
338
- return { success: true };
601
+ return performCodexAuthSwap(target);
602
+ }
603
+ // One-time migration for installs created when profile homes doubled as live
604
+ // CODEX_HOMEs: the active profile's auth.json may hold a fresher rotated
605
+ // refresh token than the system home. Best-effort: never throws.
606
+ function reconcileCodexAuthState() {
607
+ try {
608
+ const markerPath = path.join((0, accountRegistry_1.getAccountsDir)(), 'codex', '.live-auth-migrated-v1');
609
+ if (fs.existsSync(markerPath))
610
+ return;
611
+ const writeMarker = () => atomicWriteFile(markerPath, new Date().toISOString() + '\n');
612
+ const active = getActiveCodexAccount();
613
+ if (!active) {
614
+ writeMarker();
615
+ return;
616
+ }
617
+ const profileHome = getCodexProfileHome(active.id);
618
+ const profileAuthPath = path.join(profileHome, 'auth.json');
619
+ const profileAuthRaw = readFileOrNull(profileAuthPath);
620
+ if (!profileAuthRaw) {
621
+ writeMarker();
622
+ return;
623
+ }
624
+ const systemHome = getSystemCodexHome();
625
+ const liveAuthPath = path.join(systemHome, 'auth.json');
626
+ const liveAuthRaw = readFileOrNull(liveAuthPath);
627
+ if (!liveAuthRaw) {
628
+ // No live credentials (account was added via isolated login and never
629
+ // promoted). Promote the active profile's copy unless codex is logged
630
+ // in through the OS keyring.
631
+ if (!getCodexLoginStatus(systemHome).loggedIn) {
632
+ atomicWriteFile(liveAuthPath, profileAuthRaw);
633
+ }
634
+ writeMarker();
635
+ return;
636
+ }
637
+ const liveIdentity = readAuthIdentityFromRaw(liveAuthRaw);
638
+ const profileIdentity = readAuthIdentityFromRaw(profileAuthRaw);
639
+ const sameIdentity = Boolean((liveIdentity?.workspaceId && profileIdentity?.workspaceId && liveIdentity.workspaceId === profileIdentity.workspaceId) ||
640
+ (liveIdentity?.email && liveIdentity.email === profileIdentity?.email));
641
+ if (sameIdentity) {
642
+ const liveRefresh = readLastRefresh(liveAuthRaw, liveAuthPath);
643
+ const profileRefresh = readLastRefresh(profileAuthRaw, profileAuthPath);
644
+ if (profileRefresh !== null && (liveRefresh === null || profileRefresh > liveRefresh)) {
645
+ // The profile copy was the live home under the old model and holds
646
+ // the valid rotated refresh token — promote it.
647
+ stashLiveCodexAuth(liveAuthRaw, null);
648
+ atomicWriteFile(liveAuthPath, profileAuthRaw);
649
+ }
650
+ else {
651
+ atomicWriteFile(profileAuthPath, liveAuthRaw);
652
+ }
653
+ }
654
+ else {
655
+ // The live credentials belong to a different account; the live state
656
+ // wins — point the registry at the matching saved profile if there is
657
+ // one, and refresh its backup.
658
+ const matching = findProfileForIdentity(liveIdentity);
659
+ if (matching && matching.id !== active.id) {
660
+ atomicWriteFile(path.join(getCodexProfileHome(matching.id), 'auth.json'), liveAuthRaw);
661
+ (0, accountRegistry_1.setActiveSavedAccount)('codex', matching.id);
662
+ }
663
+ }
664
+ writeMarker();
665
+ }
666
+ catch {
667
+ // Reconciliation must never break startup.
668
+ }
339
669
  }
340
670
  function removeCodexAccount(profileId) {
341
671
  const removed = (0, accountRegistry_1.removeSavedAccountProfile)('codex', profileId);
@@ -96,5 +96,11 @@ function ensureDefaultCodexAccount(options) {
96
96
  async function ensureDefaultAccounts(options) {
97
97
  const claude = await ensureDefaultClaudeAccount(options);
98
98
  const codex = ensureDefaultCodexAccount(options);
99
+ try {
100
+ (0, codexProfiles_1.reconcileCodexAuthState)();
101
+ }
102
+ catch (error) {
103
+ logFailure(options, 'Codex auth reconciliation failed.', error);
104
+ }
99
105
  return { claude, codex };
100
106
  }
@@ -8,7 +8,9 @@ exports.DEFAULT_CONTEXT_WINDOW = void 0;
8
8
  exports.getModelContextWindowSize = getModelContextWindowSize;
9
9
  /** Known model context window sizes (in tokens). */
10
10
  const MODEL_CONTEXT_SIZES = {
11
- // Claude — native 1M context (Opus 4.6+, Sonnet 4.6+)
11
+ // Claude — native 1M context (Fable 5, Opus 4.6+, Sonnet 4.6+)
12
+ 'claude-fable-5': 1_000_000,
13
+ 'claude-opus-4-8': 1_000_000,
12
14
  'claude-opus-4-7': 1_000_000,
13
15
  'claude-opus-4-6': 1_000_000,
14
16
  'claude-sonnet-4-7': 1_000_000,
package/dist/modelInfo.js CHANGED
@@ -44,10 +44,26 @@ const modelContext_1 = require("./modelContext");
44
44
  * Sources:
45
45
  * - Anthropic: https://www.anthropic.com/pricing
46
46
  * - OpenAI: https://openai.com/api/pricing/
47
- * Snapshot taken: 2026-04-17. Runtime LiteLLM hydration refreshes this.
47
+ * Snapshot taken: 2026-06-09. Runtime LiteLLM hydration refreshes this.
48
+ *
49
+ * Anthropic keys appear in both dashed (`claude-opus-4-8`, the real model-ID
50
+ * form) and dotted (`claude-opus-4.8`, the LiteLLM catalog form) spellings —
51
+ * prefix matching cannot bridge the two, so both are needed.
48
52
  */
49
53
  const PRICING_TABLE = {
50
54
  // ── Anthropic: Claude ──
55
+ 'claude-fable-5': {
56
+ inputCostPerMillion: 10.0,
57
+ outputCostPerMillion: 50.0,
58
+ cacheWriteCostPerMillion: 12.5,
59
+ cacheReadCostPerMillion: 1.0,
60
+ },
61
+ 'claude-haiku-4-5': {
62
+ inputCostPerMillion: 1.0,
63
+ outputCostPerMillion: 5.0,
64
+ cacheWriteCostPerMillion: 1.25,
65
+ cacheReadCostPerMillion: 0.1,
66
+ },
51
67
  'claude-haiku-4.5': {
52
68
  inputCostPerMillion: 1.0,
53
69
  outputCostPerMillion: 5.0,
@@ -60,12 +76,24 @@ const PRICING_TABLE = {
60
76
  cacheWriteCostPerMillion: 1.0,
61
77
  cacheReadCostPerMillion: 0.08,
62
78
  },
79
+ 'claude-sonnet-4-6': {
80
+ inputCostPerMillion: 3.0,
81
+ outputCostPerMillion: 15.0,
82
+ cacheWriteCostPerMillion: 3.75,
83
+ cacheReadCostPerMillion: 0.3,
84
+ },
63
85
  'claude-sonnet-4.6': {
64
86
  inputCostPerMillion: 3.0,
65
87
  outputCostPerMillion: 15.0,
66
88
  cacheWriteCostPerMillion: 3.75,
67
89
  cacheReadCostPerMillion: 0.3,
68
90
  },
91
+ 'claude-sonnet-4-5': {
92
+ inputCostPerMillion: 3.0,
93
+ outputCostPerMillion: 15.0,
94
+ cacheWriteCostPerMillion: 3.75,
95
+ cacheReadCostPerMillion: 0.3,
96
+ },
69
97
  'claude-sonnet-4.5': {
70
98
  inputCostPerMillion: 3.0,
71
99
  outputCostPerMillion: 15.0,
@@ -78,11 +106,41 @@ const PRICING_TABLE = {
78
106
  cacheWriteCostPerMillion: 3.75,
79
107
  cacheReadCostPerMillion: 0.3,
80
108
  },
109
+ 'claude-opus-4-8': {
110
+ inputCostPerMillion: 5.0,
111
+ outputCostPerMillion: 25.0,
112
+ cacheWriteCostPerMillion: 6.25,
113
+ cacheReadCostPerMillion: 0.5,
114
+ },
115
+ 'claude-opus-4.8': {
116
+ inputCostPerMillion: 5.0,
117
+ outputCostPerMillion: 25.0,
118
+ cacheWriteCostPerMillion: 6.25,
119
+ cacheReadCostPerMillion: 0.5,
120
+ },
121
+ 'claude-opus-4-7': {
122
+ inputCostPerMillion: 5.0,
123
+ outputCostPerMillion: 25.0,
124
+ cacheWriteCostPerMillion: 6.25,
125
+ cacheReadCostPerMillion: 0.5,
126
+ },
127
+ 'claude-opus-4.7': {
128
+ inputCostPerMillion: 5.0,
129
+ outputCostPerMillion: 25.0,
130
+ cacheWriteCostPerMillion: 6.25,
131
+ cacheReadCostPerMillion: 0.5,
132
+ },
133
+ 'claude-opus-4-6': {
134
+ inputCostPerMillion: 5.0,
135
+ outputCostPerMillion: 25.0,
136
+ cacheWriteCostPerMillion: 6.25,
137
+ cacheReadCostPerMillion: 0.5,
138
+ },
81
139
  'claude-opus-4.6': {
82
- inputCostPerMillion: 15.0,
83
- outputCostPerMillion: 75.0,
84
- cacheWriteCostPerMillion: 18.75,
85
- cacheReadCostPerMillion: 1.5,
140
+ inputCostPerMillion: 5.0,
141
+ outputCostPerMillion: 25.0,
142
+ cacheWriteCostPerMillion: 6.25,
143
+ cacheReadCostPerMillion: 0.5,
86
144
  },
87
145
  'claude-opus-4.5': {
88
146
  inputCostPerMillion: 5.0,
@@ -90,6 +148,7 @@ const PRICING_TABLE = {
90
148
  cacheWriteCostPerMillion: 6.25,
91
149
  cacheReadCostPerMillion: 0.5,
92
150
  },
151
+ // Opus 4.0 / 4.1 — pre-4.5 pricing tier
93
152
  'claude-opus-4': {
94
153
  inputCostPerMillion: 15.0,
95
154
  outputCostPerMillion: 75.0,
@@ -199,7 +258,7 @@ function _clearPricingOverrides() {
199
258
  overrideSortedKeys = [];
200
259
  }
201
260
  // ── Model ID Parsing ──
202
- const CLAUDE_RE = /^claude-(haiku|sonnet|opus)-([0-9.]+)/i;
261
+ const CLAUDE_RE = /^claude-(haiku|sonnet|opus|fable)-([0-9.]+)/i;
203
262
  const LEGACY_CLAUDE_RE = /^claude-([0-9]+(?:[-.][0-9]+)?)-(haiku|sonnet|opus)(?:-|$)/i;
204
263
  const GPT_RE = /^gpt-([0-9][0-9.A-Za-z-]*)/i;
205
264
  const O_SERIES_RE = /^o([0-9]+)(-mini|-pro)?/i;
@@ -390,9 +449,10 @@ function shortModelName(modelId) {
390
449
  return modelId;
391
450
  }
392
451
  const CLAUDE_FAMILY_RANK = {
393
- opus: 0,
394
- sonnet: 1,
395
- haiku: 2,
452
+ fable: 0,
453
+ opus: 1,
454
+ sonnet: 2,
455
+ haiku: 3,
396
456
  };
397
457
  function versionRank(version) {
398
458
  if (!version)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",