@cortexkit/anthropic-auth-core 1.15.1 → 1.16.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.
@@ -70,7 +70,7 @@ export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWin
70
70
  scoped?: AccountScopedQuotaWindow[];
71
71
  checkedAt?: number;
72
72
  };
73
- export type RoutingMode = 'main-first' | 'fallback-first';
73
+ export type RoutingMode = 'main-first' | 'fallback-first' | 'sticky-balanced';
74
74
  export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w' | 'scoped', number>>;
75
75
  export type KillswitchConfig = {
76
76
  enabled?: boolean;
@@ -134,6 +134,7 @@ export type AccountStorage = {
134
134
  };
135
135
  cacheKeep?: {
136
136
  enabled?: boolean;
137
+ always?: boolean;
137
138
  startHour?: number;
138
139
  endHour?: number;
139
140
  subagents?: boolean;
@@ -189,7 +190,13 @@ export declare function getAccountStoragePath(): string;
189
190
  export declare function getAccountStatePath(configPath?: string): string;
190
191
  export declare function createEmptyStorage(): AccountStorage;
191
192
  export declare function loadAccounts(path?: string): Promise<AccountStorage | null>;
192
- export declare function saveAccounts(storage: AccountStorage, path?: string): Promise<void>;
193
+ export interface SaveAccountsOptions {
194
+ /** Account ids intentionally removed by this mutation. */
195
+ removedAccountIds?: readonly string[];
196
+ /** Preserve disk order when a stale snapshot is missing newer accounts. */
197
+ preserveExistingAccountOrder?: boolean;
198
+ }
199
+ export declare function saveAccounts(storage: AccountStorage, path?: string, options?: SaveAccountsOptions): Promise<void>;
193
200
  export declare function saveAccountState(storage: AccountStorage, path?: string, scope?: AccountStateSaveScope): Promise<void>;
194
201
  export declare function acquireRefreshFileLock(options: {
195
202
  name: string;
@@ -211,10 +218,15 @@ export declare function setDumpPersistentEnabled(enabled: boolean, path?: string
211
218
  export declare function isFastModePersistentlyEnabled(storage: AccountStorage | null): boolean;
212
219
  export declare function setFastModePersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
213
220
  export declare function setCacheKeepPersistentWindow(startHour: number, endHour: number, path?: string): Promise<AccountStorage>;
221
+ export declare function setCacheKeepPersistentAlways(path?: string): Promise<AccountStorage>;
214
222
  export declare function setCacheKeepPersistentEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
215
223
  export declare function isCacheKeepSubagentsEnabled(storage: AccountStorage | null): boolean;
216
224
  export declare function setCacheKeepSubagentsEnabled(enabled: boolean, path?: string): Promise<AccountStorage>;
217
225
  export declare function shouldFallbackStatus(status: number, storage: AccountStorage | null): boolean;
226
+ export declare function getQuotaMinimumRemainingThresholds(storage: AccountStorage | null): {
227
+ five_hour: number;
228
+ seven_day: number;
229
+ };
218
230
  export declare function getRefreshIntervalMs(storage: AccountStorage | null): number;
219
231
  export declare function hashRefreshToken(refreshToken: string): string;
220
232
  export declare function buildRefreshOperationError(input: {
package/dist/accounts.js CHANGED
@@ -517,19 +517,81 @@ async function writeJsonAtomic(path, value) {
517
517
  throw error;
518
518
  }
519
519
  }
520
- export function saveAccounts(storage, path = getAccountStoragePath()) {
521
- const resolvedPath = path;
522
- return enqueueSave(() => saveAccountsLocked(storage, resolvedPath));
523
- }
524
- async function saveAccountsLocked(storage, path) {
525
- const existing = await loadExistingTopLevelFields(path);
526
- const nextConfig = { ...existing, ...configFromStorage(storage) };
527
- await writeJsonAtomic(path, nextConfig);
528
- await saveAccountStateUnlocked(storage, path, {
529
- mainQuota: true,
530
- mainRefresh: true,
531
- accounts: true,
520
+ function sameAccountIdentity(left, right) {
521
+ return (left.id === right.id ||
522
+ Boolean(left.label && right.label && left.label === right.label));
523
+ }
524
+ function mergeAccountsForSave(existing, incoming, options) {
525
+ const removedIds = new Set(options.removedAccountIds ?? []);
526
+ const current = existing.filter((account) => !removedIds.has(account.id));
527
+ const next = incoming.filter((account) => !removedIds.has(account.id));
528
+ const missing = current.filter((account) => !next.some((candidate) => sameAccountIdentity(candidate, account)));
529
+ if (!missing.length)
530
+ return [...next];
531
+ if (options.preserveExistingAccountOrder === false) {
532
+ return [...next, ...missing];
533
+ }
534
+ const usedIncoming = new Set();
535
+ const merged = current.map((account) => {
536
+ const index = next.findIndex((candidate, candidateIndex) => !usedIncoming.has(candidateIndex) &&
537
+ sameAccountIdentity(candidate, account));
538
+ const candidate = next[index];
539
+ if (!candidate)
540
+ return account;
541
+ usedIncoming.add(index);
542
+ return candidate;
532
543
  });
544
+ for (let index = 0; index < next.length; index++) {
545
+ const candidate = next[index];
546
+ if (!usedIncoming.has(index) && candidate)
547
+ merged.push(candidate);
548
+ }
549
+ return merged;
550
+ }
551
+ const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
552
+ const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
553
+ async function acquireAccountConfigWriteLock(path) {
554
+ await mkdir(dirname(path), { recursive: true });
555
+ const deadline = Date.now() + ACCOUNT_CONFIG_LOCK_WAIT_MS;
556
+ while (true) {
557
+ const lock = await acquireRefreshFileLock({
558
+ name: 'config-write',
559
+ ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS,
560
+ path,
561
+ renew: true,
562
+ });
563
+ if (lock)
564
+ return lock;
565
+ if (Date.now() >= deadline) {
566
+ throw new Error('Timed out waiting for the account configuration write lock');
567
+ }
568
+ await new Promise((resolve) => setTimeout(resolve, 25));
569
+ }
570
+ }
571
+ export function saveAccounts(storage, path = getAccountStoragePath(), options = {}) {
572
+ const resolvedPath = path;
573
+ return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
574
+ }
575
+ async function saveAccountsLocked(storage, path, options) {
576
+ const lock = await acquireAccountConfigWriteLock(path);
577
+ try {
578
+ const current = await loadAccounts(path);
579
+ const nextStorage = {
580
+ ...storage,
581
+ accounts: mergeAccountsForSave(current?.accounts ?? [], storage.accounts, options),
582
+ };
583
+ const existing = await loadExistingTopLevelFields(path);
584
+ const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
585
+ await writeJsonAtomic(path, nextConfig);
586
+ await saveAccountStateUnlocked(nextStorage, path, {
587
+ mainQuota: true,
588
+ mainRefresh: true,
589
+ accounts: true,
590
+ });
591
+ }
592
+ finally {
593
+ await lock.release();
594
+ }
533
595
  }
534
596
  function applyMainQuotaStatePatch(state, storage) {
535
597
  state.main = state.main ?? {};
@@ -877,13 +939,27 @@ export async function setFastModePersistentEnabled(enabled, path = getAccountSto
877
939
  export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
878
940
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
879
941
  storage.cacheKeep = {
942
+ ...(storage.cacheKeep ?? {}),
880
943
  enabled: true,
944
+ always: false,
881
945
  startHour,
882
946
  endHour,
883
947
  };
884
948
  await saveAccounts(storage, path);
885
949
  return storage;
886
950
  }
951
+ export async function setCacheKeepPersistentAlways(path = getAccountStoragePath()) {
952
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
953
+ storage.cacheKeep = {
954
+ ...(storage.cacheKeep ?? {}),
955
+ enabled: true,
956
+ always: true,
957
+ };
958
+ delete storage.cacheKeep.startHour;
959
+ delete storage.cacheKeep.endHour;
960
+ await saveAccounts(storage, path);
961
+ return storage;
962
+ }
887
963
  export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
888
964
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
889
965
  storage.cacheKeep = {
@@ -911,7 +987,7 @@ function getFallbackStatuses(storage) {
911
987
  export function shouldFallbackStatus(status, storage) {
912
988
  return getFallbackStatuses(storage).includes(status);
913
989
  }
914
- function normalizeThresholds(storage) {
990
+ export function getQuotaMinimumRemainingThresholds(storage) {
915
991
  const configured = storage?.quota?.minimumRemaining || {};
916
992
  return {
917
993
  five_hour: configured.five_hour ??
@@ -1141,7 +1217,7 @@ export function quotaSnapshotPassesModelScope(quota, model) {
1141
1217
  export function quotaSnapshotPassesPolicy(quota, storage) {
1142
1218
  if (!quotaEnabled(storage))
1143
1219
  return true;
1144
- const thresholds = normalizeThresholds(storage);
1220
+ const thresholds = getQuotaMinimumRemainingThresholds(storage);
1145
1221
  for (const key of ['five_hour', 'seven_day']) {
1146
1222
  const window = quota?.[key];
1147
1223
  if (!window)
@@ -1293,8 +1369,9 @@ export async function removeAccountPersistent(id, path = getAccountStoragePath()
1293
1369
  if (!storage)
1294
1370
  return false;
1295
1371
  const existed = removeAccount(storage, id);
1296
- if (existed)
1297
- await saveAccounts(storage, path);
1372
+ if (existed) {
1373
+ await saveAccounts(storage, path, { removedAccountIds: [id] });
1374
+ }
1298
1375
  return existed;
1299
1376
  }
1300
1377
  export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
@@ -1302,7 +1379,7 @@ export async function reorderAccountsPersistent(orderedIds, path = getAccountSto
1302
1379
  if (!storage)
1303
1380
  return;
1304
1381
  reorderAccounts(storage, orderedIds);
1305
- await saveAccounts(storage, path);
1382
+ await saveAccounts(storage, path, { preserveExistingAccountOrder: false });
1306
1383
  }
1307
1384
  export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
1308
1385
  const storage = await loadAccounts(path);
@@ -1321,7 +1398,7 @@ export async function addAccountPersistent(account, path = getAccountStoragePath
1321
1398
  export function getQuotaNextRefreshAt(quota, storage, now) {
1322
1399
  if (!quotaEnabled(storage))
1323
1400
  return now + getQuotaCheckIntervalMs(storage);
1324
- const thresholds = normalizeThresholds(storage);
1401
+ const thresholds = getQuotaMinimumRemainingThresholds(storage);
1325
1402
  const blockedResetTimes = [];
1326
1403
  for (const key of ['five_hour', 'seven_day']) {
1327
1404
  const window = quota?.[key];
@@ -1353,8 +1430,11 @@ function quotaSnapshotIsFresh(quota, storage, now) {
1353
1430
  return Boolean(window && now - window.checkedAt < maxAge);
1354
1431
  });
1355
1432
  }
1356
- function quotaIsStale(account, storage, now) {
1357
- return !quotaSnapshotIsFresh(account.quota, storage, now);
1433
+ function quotaIsStale(account, storage, now, modelId) {
1434
+ if (!quotaSnapshotIsFresh(account.quota, storage, now))
1435
+ return true;
1436
+ const scoped = getScopedQuotaWindowForModel(account.quota, modelId);
1437
+ return Boolean(scoped && now - scoped.checkedAt >= getQuotaCheckIntervalMs(storage));
1358
1438
  }
1359
1439
  function cachedQuotaWindowStillRelevant(window, now) {
1360
1440
  if (!window)
@@ -1656,8 +1736,8 @@ export class FallbackAccountManager {
1656
1736
  }
1657
1737
  this.seedFallbackQuota(next, storage);
1658
1738
  const stale = this.quotaManager
1659
- ? this.quotaManager.isFallbackStale(next.id, next.access)
1660
- : quotaIsStale(next, storage, this.now());
1739
+ ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId)
1740
+ : quotaIsStale(next, storage, this.now(), options.modelId);
1661
1741
  // Skip the request-time refresh when this account's quota API is
1662
1742
  // backed off (recent 429/5xx). Hitting it again would extend the
1663
1743
  // backoff; evaluate policy on the cached/seeded quota instead. Mirrors
@@ -0,0 +1,21 @@
1
+ export declare const CACHE_KEEP_REGISTRY_LEASE_MS: number;
2
+ export type CacheKeepTrackedSession = {
3
+ id: string;
4
+ cacheExpiresAt: number;
5
+ nextPrewarmAt: number;
6
+ };
7
+ export declare function getDefaultCacheKeepRegistryDirectory(scope: 'opencode' | 'pi'): string;
8
+ export declare class CacheKeepSessionRegistry {
9
+ private readonly options;
10
+ private readonly filePath;
11
+ private writeChain;
12
+ constructor(options: {
13
+ directory: string;
14
+ now?: () => number;
15
+ leaseMs?: number;
16
+ instanceId?: string;
17
+ });
18
+ publish(sessions: readonly CacheKeepTrackedSession[]): Promise<void>;
19
+ list(localSessions?: readonly CacheKeepTrackedSession[]): Promise<CacheKeepTrackedSession[]>;
20
+ dispose(): Promise<void>;
21
+ }
@@ -0,0 +1,124 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, rm, writeFile, } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ export const CACHE_KEEP_REGISTRY_LEASE_MS = 3 * 60_000;
6
+ function normalizeSession(value) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value))
8
+ return;
9
+ const record = value;
10
+ if (typeof record.id !== 'string' ||
11
+ record.id.length === 0 ||
12
+ typeof record.cacheExpiresAt !== 'number' ||
13
+ !Number.isFinite(record.cacheExpiresAt) ||
14
+ typeof record.nextPrewarmAt !== 'number' ||
15
+ !Number.isFinite(record.nextPrewarmAt)) {
16
+ return;
17
+ }
18
+ return {
19
+ id: record.id,
20
+ cacheExpiresAt: record.cacheExpiresAt,
21
+ nextPrewarmAt: record.nextPrewarmAt,
22
+ };
23
+ }
24
+ function normalizeRecord(value) {
25
+ if (!value || typeof value !== 'object' || Array.isArray(value))
26
+ return;
27
+ const record = value;
28
+ if (record.version !== 1 ||
29
+ typeof record.updatedAt !== 'number' ||
30
+ !Number.isFinite(record.updatedAt) ||
31
+ !Array.isArray(record.sessions)) {
32
+ return;
33
+ }
34
+ return {
35
+ version: 1,
36
+ updatedAt: record.updatedAt,
37
+ sessions: record.sessions.flatMap((session) => {
38
+ const normalized = normalizeSession(session);
39
+ return normalized ? [normalized] : [];
40
+ }),
41
+ };
42
+ }
43
+ export function getDefaultCacheKeepRegistryDirectory(scope) {
44
+ return join(tmpdir(), 'opencode-anthropic-auth', 'cachekeep-sessions', scope);
45
+ }
46
+ export class CacheKeepSessionRegistry {
47
+ options;
48
+ filePath;
49
+ writeChain = Promise.resolve();
50
+ constructor(options) {
51
+ this.options = options;
52
+ const instanceId = options.instanceId ?? `${process.pid}-${randomUUID()}`;
53
+ this.filePath = join(options.directory, `${instanceId}.json`);
54
+ }
55
+ publish(sessions) {
56
+ const snapshot = sessions.map((session) => ({ ...session }));
57
+ this.writeChain = this.writeChain
58
+ .catch(() => { })
59
+ .then(async () => {
60
+ if (snapshot.length === 0) {
61
+ await rm(this.filePath, { force: true });
62
+ return;
63
+ }
64
+ await mkdir(this.options.directory, { recursive: true, mode: 0o700 });
65
+ const tempPath = `${this.filePath}.${randomUUID()}.tmp`;
66
+ const record = {
67
+ version: 1,
68
+ updatedAt: this.options.now?.() ?? Date.now(),
69
+ sessions: snapshot,
70
+ };
71
+ await writeFile(tempPath, `${JSON.stringify(record)}\n`, {
72
+ mode: 0o600,
73
+ });
74
+ await rename(tempPath, this.filePath);
75
+ });
76
+ return this.writeChain;
77
+ }
78
+ async list(localSessions = []) {
79
+ await this.writeChain.catch(() => { });
80
+ const now = this.options.now?.() ?? Date.now();
81
+ const leaseMs = this.options.leaseMs ?? CACHE_KEEP_REGISTRY_LEASE_MS;
82
+ let names;
83
+ try {
84
+ names = (await readdir(this.options.directory)).filter((name) => name.endsWith('.json'));
85
+ }
86
+ catch {
87
+ names = [];
88
+ }
89
+ const records = await Promise.all(names.map(async (name) => {
90
+ try {
91
+ const raw = await readFile(join(this.options.directory, name), 'utf8');
92
+ return normalizeRecord(JSON.parse(raw));
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }));
98
+ const sessions = new Map();
99
+ for (const record of records) {
100
+ if (!record ||
101
+ now - record.updatedAt > leaseMs ||
102
+ record.updatedAt > now) {
103
+ continue;
104
+ }
105
+ for (const session of record.sessions) {
106
+ const existing = sessions.get(session.id);
107
+ if (!existing || session.cacheExpiresAt > existing.cacheExpiresAt) {
108
+ sessions.set(session.id, session);
109
+ }
110
+ }
111
+ }
112
+ for (const session of localSessions) {
113
+ const existing = sessions.get(session.id);
114
+ if (!existing || session.cacheExpiresAt > existing.cacheExpiresAt) {
115
+ sessions.set(session.id, { ...session });
116
+ }
117
+ }
118
+ return [...sessions.values()].sort((left, right) => left.id.localeCompare(right.id));
119
+ }
120
+ async dispose() {
121
+ await this.writeChain.catch(() => { });
122
+ await rm(this.filePath, { force: true });
123
+ }
124
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AccountStorage } from './accounts.ts';
2
+ import type { CacheKeepTrackedSession } from './cachekeep-registry.ts';
2
3
  export declare const CLAUDE_CACHE_KEEP_COMMAND_NAME = "claude-cachekeep";
3
4
  export declare const CACHE_KEEP_TTL_MS: number;
4
5
  export declare const CACHE_KEEP_PREWARM_LEAD_MS: number;
@@ -16,6 +17,8 @@ export type CacheKeepCommandAction = {
16
17
  type: 'status';
17
18
  } | {
18
19
  type: 'disable';
20
+ } | {
21
+ type: 'always';
19
22
  } | {
20
23
  type: 'window';
21
24
  startHour: number;
@@ -28,23 +31,29 @@ export type CacheKeepCommandAction = {
28
31
  };
29
32
  export type CacheKeepStatus = {
30
33
  enabled: boolean;
34
+ always?: boolean;
31
35
  window?: CacheKeepWindow;
32
36
  trackedSessions?: number;
37
+ trackedSessionDetails?: CacheKeepTrackedSession[];
33
38
  nextPrewarmAt?: number;
34
39
  hybridActive?: boolean;
35
40
  };
36
41
  export declare function parseCacheKeepCommandAction(input: string): CacheKeepCommandAction;
37
42
  export declare function getCacheKeepWindow(storage: AccountStorage | null): CacheKeepWindow | undefined;
43
+ export declare function isCacheKeepAlways(storage: AccountStorage | null): boolean;
38
44
  export declare function isCacheKeepPersistentlyEnabled(storage: AccountStorage | null): boolean;
39
45
  export declare function isCacheKeepHybridActive(storage: AccountStorage | null): boolean;
40
46
  export declare function isWithinCacheKeepWindow(window: CacheKeepWindow | undefined, now?: Date): boolean;
47
+ export declare function isCacheKeepActiveNow(storage: AccountStorage | null, now?: Date): boolean;
41
48
  export declare function localDayKey(now?: Date): string;
42
49
  export declare function buildCacheKeepStatusSummary(status: CacheKeepStatus): string;
43
50
  export declare function executeCacheKeepCommand(input: {
44
51
  argumentsText: string;
45
52
  enabled?: boolean;
53
+ always?: boolean;
46
54
  window?: CacheKeepWindow;
47
55
  trackedSessions?: number;
56
+ trackedSessionDetails?: CacheKeepTrackedSession[];
48
57
  nextPrewarmAt?: number;
49
58
  hybridActive?: boolean;
50
59
  }): string;
@@ -86,14 +95,20 @@ export declare class CacheKeepManager {
86
95
  now?: () => number;
87
96
  prewarmTimeoutMs?: number;
88
97
  prepareHeaders?: (headers: Headers, target: CacheKeepTarget) => Promise<Headers> | Headers;
98
+ onTrackedSessionsChanged?: (sessions: readonly CacheKeepTrackedSession[]) => Promise<void> | void;
89
99
  });
90
100
  start(): void;
91
101
  stop(): void;
92
- stats(window?: CacheKeepWindow, now?: number): {
102
+ stats(window?: CacheKeepWindow, now?: number, always?: boolean): {
93
103
  trackedSessions: number;
94
104
  nextPrewarmAt: number | undefined;
95
105
  };
106
+ trackedSessions(): CacheKeepTrackedSession[];
107
+ trackedOAuthRoute(sessionId: string): {
108
+ oauthAccountId?: string;
109
+ } | undefined;
96
110
  trackedCount(): number;
111
+ private publishTrackedSessions;
97
112
  private totalBodyBytes;
98
113
  private evictOldestTarget;
99
114
  private pruneTargets;
package/dist/cachekeep.js CHANGED
@@ -14,7 +14,7 @@ const STATUS_TITLE = '## Claude Cache Keep Status';
14
14
  const ENABLED_TITLE = '## Claude Cache Keep Enabled';
15
15
  const DISABLED_TITLE = '## Claude Cache Keep Disabled';
16
16
  const USAGE_TITLE = '## Claude Cache Keep Usage';
17
- const USAGE = 'Usage: `/claude-cachekeep`, `/claude-cachekeep off`, or `/claude-cachekeep HH-HH`.';
17
+ const USAGE = 'Usage: `/claude-cachekeep`, `/claude-cachekeep always`, `/claude-cachekeep off`, or `/claude-cachekeep HH-HH`.';
18
18
  function padHour(hour) {
19
19
  return String(hour).padStart(2, '0');
20
20
  }
@@ -24,6 +24,8 @@ export function parseCacheKeepCommandAction(input) {
24
24
  return { type: 'status' };
25
25
  if (trimmed === 'off')
26
26
  return { type: 'disable' };
27
+ if (trimmed === 'always')
28
+ return { type: 'always' };
27
29
  if (trimmed === 'subagents on')
28
30
  return { type: 'subagents', enabled: true };
29
31
  if (trimmed === 'subagents off')
@@ -58,8 +60,12 @@ export function getCacheKeepWindow(storage) {
58
60
  }
59
61
  return { startHour, endHour };
60
62
  }
63
+ export function isCacheKeepAlways(storage) {
64
+ return (storage?.cacheKeep?.enabled === true && storage.cacheKeep.always === true);
65
+ }
61
66
  export function isCacheKeepPersistentlyEnabled(storage) {
62
- return storage?.cacheKeep?.enabled === true && !!getCacheKeepWindow(storage);
67
+ return (storage?.cacheKeep?.enabled === true &&
68
+ (isCacheKeepAlways(storage) || !!getCacheKeepWindow(storage)));
63
69
  }
64
70
  export function isCacheKeepHybridActive(storage) {
65
71
  return (isCacheKeepPersistentlyEnabled(storage) &&
@@ -75,6 +81,12 @@ export function isWithinCacheKeepWindow(window, now = new Date()) {
75
81
  }
76
82
  return hour >= window.startHour || hour < window.endHour;
77
83
  }
84
+ export function isCacheKeepActiveNow(storage, now = new Date()) {
85
+ if (storage?.cacheKeep?.enabled !== true)
86
+ return false;
87
+ return (isCacheKeepAlways(storage) ||
88
+ isWithinCacheKeepWindow(getCacheKeepWindow(storage), now));
89
+ }
78
90
  export function localDayKey(now = new Date()) {
79
91
  return `${now.getFullYear()}-${padHour(now.getMonth() + 1)}-${padHour(now.getDate())}`;
80
92
  }
@@ -88,13 +100,18 @@ function localWindowKey(window, now = new Date()) {
88
100
  }
89
101
  return localDayKey(now);
90
102
  }
103
+ function cacheKeepPeriodKey(storage, window, now = new Date()) {
104
+ return isCacheKeepAlways(storage) ? 'always' : localWindowKey(window, now);
105
+ }
91
106
  export function buildCacheKeepStatusSummary(status) {
92
- const window = status.window
93
- ? `${padHour(status.window.startHour)}-${padHour(status.window.endHour)}`
94
- : 'not configured';
107
+ const schedule = status.always
108
+ ? 'always (while this process is running)'
109
+ : status.window
110
+ ? `${padHour(status.window.startHour)}-${padHour(status.window.endHour)}`
111
+ : 'not configured';
95
112
  const lines = [
96
113
  `Cache keep: ${status.enabled ? 'enabled' : 'disabled'}`,
97
- `Window: ${window}`,
114
+ `Schedule: ${schedule}`,
98
115
  'Mode requirement: `/claude-cache mode hybrid` must be active.',
99
116
  ];
100
117
  if (typeof status.hybridActive === 'boolean') {
@@ -103,6 +120,12 @@ export function buildCacheKeepStatusSummary(status) {
103
120
  if (typeof status.trackedSessions === 'number') {
104
121
  lines.push(`Tracked sessions: ${status.trackedSessions}`);
105
122
  }
123
+ if (status.trackedSessionDetails?.length) {
124
+ lines.push('Sessions:');
125
+ for (const session of status.trackedSessionDetails) {
126
+ lines.push(`- ${session.id}`);
127
+ }
128
+ }
106
129
  if (status.nextPrewarmAt) {
107
130
  lines.push(`Next prewarm: ${new Date(status.nextPrewarmAt).toLocaleString()}`);
108
131
  }
@@ -112,8 +135,10 @@ export function executeCacheKeepCommand(input) {
112
135
  const action = parseCacheKeepCommandAction(input.argumentsText);
113
136
  const status = {
114
137
  enabled: input.enabled ?? false,
138
+ always: input.always,
115
139
  window: input.window,
116
140
  trackedSessions: input.trackedSessions,
141
+ trackedSessionDetails: input.trackedSessionDetails,
117
142
  nextPrewarmAt: input.nextPrewarmAt,
118
143
  hybridActive: input.hybridActive,
119
144
  };
@@ -127,6 +152,18 @@ export function executeCacheKeepCommand(input) {
127
152
  buildCacheKeepStatusSummary({ ...status, enabled: false }),
128
153
  ].join('\n');
129
154
  }
155
+ if (action.type === 'always') {
156
+ return [
157
+ ENABLED_TITLE,
158
+ '',
159
+ buildCacheKeepStatusSummary({
160
+ ...status,
161
+ enabled: true,
162
+ always: true,
163
+ window: undefined,
164
+ }),
165
+ ].join('\n');
166
+ }
130
167
  if (action.type === 'window') {
131
168
  return [
132
169
  ENABLED_TITLE,
@@ -134,6 +171,7 @@ export function executeCacheKeepCommand(input) {
134
171
  buildCacheKeepStatusSummary({
135
172
  ...status,
136
173
  enabled: true,
174
+ always: false,
137
175
  window: { startHour: action.startHour, endHour: action.endHour },
138
176
  }),
139
177
  ].join('\n');
@@ -218,17 +256,47 @@ export class CacheKeepManager {
218
256
  clearInterval(this.timer);
219
257
  this.timer = null;
220
258
  }
221
- stats(window, now = this.options.now?.() ?? Date.now()) {
222
- const today = localWindowKey(window, new Date(now));
259
+ stats(window, now = this.options.now?.() ?? Date.now(), always = false) {
260
+ const today = always ? 'always' : localWindowKey(window, new Date(now));
223
261
  const targets = [...this.targets.values()].filter((target) => target.dayKey === today);
224
262
  const nextPrewarmAt = targets.length
225
263
  ? Math.min(...targets.map((target) => target.cacheExpiresAt - CACHE_KEEP_PREWARM_LEAD_MS))
226
264
  : undefined;
227
265
  return { trackedSessions: targets.length, nextPrewarmAt };
228
266
  }
267
+ trackedSessions() {
268
+ return [...this.targets.values()]
269
+ .map((target) => ({
270
+ id: target.id,
271
+ cacheExpiresAt: target.cacheExpiresAt,
272
+ nextPrewarmAt: target.cacheExpiresAt - CACHE_KEEP_PREWARM_LEAD_MS,
273
+ }))
274
+ .sort((left, right) => left.id.localeCompare(right.id));
275
+ }
276
+ trackedOAuthRoute(sessionId) {
277
+ const target = this.targets.get(sessionId);
278
+ return target ? { oauthAccountId: target.oauthAccountId } : undefined;
279
+ }
229
280
  trackedCount() {
230
281
  return this.targets.size;
231
282
  }
283
+ publishTrackedSessions() {
284
+ const callback = this.options.onTrackedSessionsChanged;
285
+ if (!callback)
286
+ return;
287
+ try {
288
+ void Promise.resolve(callback(this.trackedSessions())).catch((error) => {
289
+ logger.warn('cachekeep', 'failed to publish tracked sessions', {
290
+ error: error instanceof Error ? error.message : String(error),
291
+ });
292
+ });
293
+ }
294
+ catch (error) {
295
+ logger.warn('cachekeep', 'failed to publish tracked sessions', {
296
+ error: error instanceof Error ? error.message : String(error),
297
+ });
298
+ }
299
+ }
232
300
  totalBodyBytes() {
233
301
  return [...this.targets.values()].reduce((sum, target) => sum + target.bodyText.length, 0);
234
302
  }
@@ -270,10 +338,10 @@ export class CacheKeepManager {
270
338
  }
271
339
  const now = this.options.now?.() ?? Date.now();
272
340
  const window = getCacheKeepWindow(input.storage);
273
- if (!isWithinCacheKeepWindow(window, new Date(now))) {
274
- return { tracked: false, reason: 'outside configured window' };
341
+ if (!isCacheKeepActiveNow(input.storage, new Date(now))) {
342
+ return { tracked: false, reason: 'outside configured schedule' };
275
343
  }
276
- const today = localWindowKey(window, new Date(now));
344
+ const today = cacheKeepPeriodKey(input.storage, window, new Date(now));
277
345
  this.pruneTargets(now, today);
278
346
  if (this.targets.has(input.sessionId))
279
347
  this.targets.delete(input.sessionId);
@@ -291,6 +359,7 @@ export class CacheKeepManager {
291
359
  oauthAccountId: input.oauthAccountId,
292
360
  });
293
361
  this.pruneTargets(now, today);
362
+ this.publishTrackedSessions();
294
363
  this.start();
295
364
  return { tracked: true };
296
365
  }
@@ -314,16 +383,25 @@ export class CacheKeepManager {
314
383
  const storage = await this.options.loadStorage();
315
384
  const window = getCacheKeepWindow(storage);
316
385
  const now = this.options.now?.() ?? Date.now();
317
- const today = localWindowKey(window, new Date(now));
386
+ const today = cacheKeepPeriodKey(storage, window, new Date(now));
387
+ if (isCacheKeepAlways(storage)) {
388
+ for (const target of this.targets.values())
389
+ target.dayKey = 'always';
390
+ }
318
391
  this.pruneTargets(now, today);
319
392
  if (!this.targets.size) {
393
+ this.publishTrackedSessions();
320
394
  this.stop();
321
395
  return;
322
396
  }
323
- if (!isCacheKeepHybridActive(storage))
397
+ if (!isCacheKeepHybridActive(storage)) {
398
+ this.publishTrackedSessions();
324
399
  return;
325
- if (!isWithinCacheKeepWindow(window, new Date(now)))
400
+ }
401
+ if (!isCacheKeepActiveNow(storage, new Date(now))) {
402
+ this.publishTrackedSessions();
326
403
  return;
404
+ }
327
405
  logger.debug('cachekeep', 'fired', { targets: this.targets.size });
328
406
  const dueAt = now + CACHE_KEEP_PREWARM_LEAD_MS;
329
407
  for (const target of this.targets.values()) {
@@ -331,6 +409,7 @@ export class CacheKeepManager {
331
409
  continue;
332
410
  await this.prewarm(target, now);
333
411
  }
412
+ this.publishTrackedSessions();
334
413
  }
335
414
  async sendPrewarm(target) {
336
415
  const prewarm = await buildCacheKeepPrewarmBody(target.bodyText);