sidekick-shared 0.25.0 → 0.26.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +49 -3
  3. package/dist/accountChanges.js +22 -3
  4. package/dist/accountRegistry.d.ts +22 -1
  5. package/dist/accountRegistry.js +37 -14
  6. package/dist/accountStatus.d.ts +9 -1
  7. package/dist/accountStatus.js +3 -3
  8. package/dist/accounts.d.ts +3 -1
  9. package/dist/accounts.js +84 -64
  10. package/dist/aggregation/EventAggregator.d.ts +15 -2
  11. package/dist/aggregation/EventAggregator.js +54 -9
  12. package/dist/aggregation/costProvenance.d.ts +14 -0
  13. package/dist/aggregation/costProvenance.js +35 -0
  14. package/dist/aggregation/snapshot.d.ts +13 -2
  15. package/dist/aggregation/snapshot.js +57 -6
  16. package/dist/aggregation/types.d.ts +30 -1
  17. package/dist/analytics/compactionLedger.js +15 -5
  18. package/dist/analytics/qualityScore.js +2 -1
  19. package/dist/browser.d.ts +11 -1
  20. package/dist/browser.js +29 -1
  21. package/dist/codexProfiles.d.ts +3 -1
  22. package/dist/codexProfiles.js +4 -2
  23. package/dist/codexQuota.d.ts +2 -0
  24. package/dist/codexQuota.js +11 -66
  25. package/dist/failingTools.d.ts +37 -0
  26. package/dist/failingTools.js +70 -0
  27. package/dist/formatters/sessionDump.js +13 -7
  28. package/dist/formatting.d.ts +13 -0
  29. package/dist/formatting.js +35 -0
  30. package/dist/historicalStore.d.ts +47 -0
  31. package/dist/historicalStore.js +312 -0
  32. package/dist/index.d.ts +46 -12
  33. package/dist/index.js +112 -24
  34. package/dist/node.d.ts +19 -0
  35. package/dist/node.js +47 -1
  36. package/dist/observedContextWindows.d.ts +7 -0
  37. package/dist/observedContextWindows.js +17 -2
  38. package/dist/observedSessionCollector.d.ts +3 -8
  39. package/dist/observedSessionCollector.js +10 -27
  40. package/dist/parsers/sessionActivityDetector.d.ts +24 -2
  41. package/dist/parsers/sessionActivityDetector.js +95 -17
  42. package/dist/parsers/sessionPathResolver.d.ts +11 -0
  43. package/dist/parsers/sessionPathResolver.js +13 -10
  44. package/dist/peakHours.d.ts +34 -3
  45. package/dist/peakHours.js +92 -7
  46. package/dist/pricingCatalog.d.ts +7 -0
  47. package/dist/pricingCatalog.js +8 -5
  48. package/dist/providers/claudeCode.js +11 -96
  49. package/dist/providers/codex.d.ts +2 -0
  50. package/dist/providers/codex.js +94 -259
  51. package/dist/providers/detect.d.ts +2 -0
  52. package/dist/providers/detect.js +29 -24
  53. package/dist/providers/factory.d.ts +6 -0
  54. package/dist/providers/openCode.d.ts +2 -0
  55. package/dist/providers/openCode.js +105 -121
  56. package/dist/providers/rolloutWalker.d.ts +53 -0
  57. package/dist/providers/rolloutWalker.js +209 -0
  58. package/dist/providers/types.d.ts +30 -0
  59. package/dist/quota.d.ts +30 -3
  60. package/dist/quota.js +35 -3
  61. package/dist/quotaHistory.d.ts +1 -1
  62. package/dist/quotaHistory.js +36 -35
  63. package/dist/quotaResolver.d.ts +81 -0
  64. package/dist/quotaResolver.js +213 -0
  65. package/dist/quotaSnapshots.d.ts +15 -2
  66. package/dist/quotaSnapshots.js +25 -3
  67. package/dist/quotaThresholds.d.ts +60 -0
  68. package/dist/quotaThresholds.js +90 -0
  69. package/dist/report/htmlReportGenerator.js +10 -4
  70. package/dist/report/sessionReportInputs.d.ts +23 -0
  71. package/dist/report/sessionReportInputs.js +34 -0
  72. package/dist/schemas/index.d.ts +2 -0
  73. package/dist/schemas/index.js +10 -1
  74. package/dist/schemas/quota.d.ts +139 -0
  75. package/dist/schemas/quota.js +17 -2
  76. package/dist/schemas/quotaHistory.d.ts +1 -0
  77. package/dist/schemas/quotaHistory.js +1 -1
  78. package/dist/schemas/sessionEvent.d.ts +6 -0
  79. package/dist/schemas/sessionEvent.js +1 -0
  80. package/dist/schemas/stateFile.d.ts +198 -0
  81. package/dist/schemas/stateFile.js +77 -0
  82. package/dist/schemas/transcript.js +2 -0
  83. package/dist/sessionFingerprint.d.ts +25 -0
  84. package/dist/sessionFingerprint.js +46 -0
  85. package/dist/sessionPreviews.d.ts +7 -1
  86. package/dist/sessionPreviews.js +15 -3
  87. package/dist/sessionStats.d.ts +51 -0
  88. package/dist/sessionStats.js +166 -0
  89. package/dist/stateFile.d.ts +113 -0
  90. package/dist/stateFile.js +160 -0
  91. package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
  92. package/dist/statusline/claudeStatuslinePayload.js +152 -0
  93. package/dist/statusline/formatter.d.ts +13 -0
  94. package/dist/statusline/formatter.js +48 -6
  95. package/dist/statusline/index.d.ts +10 -2
  96. package/dist/statusline/index.js +19 -1
  97. package/dist/tokenSummary.d.ts +62 -0
  98. package/dist/tokenSummary.js +72 -0
  99. package/dist/transcript.d.ts +5 -0
  100. package/dist/transcript.js +2 -0
  101. package/dist/types/observedSessionV1.d.ts +2 -0
  102. package/dist/types/observedSessionV1.js +75 -18
  103. package/dist/types/sessionEvent.d.ts +2 -0
  104. package/dist/usage/billingBlocks.d.ts +85 -0
  105. package/dist/usage/billingBlocks.js +153 -0
  106. package/dist/usage/importSessionHistory.d.ts +53 -0
  107. package/dist/usage/importSessionHistory.js +88 -0
  108. package/dist/usage/usageEvents.d.ts +92 -0
  109. package/dist/usage/usageEvents.js +322 -0
  110. package/dist/usage/usageReports.d.ts +76 -0
  111. package/dist/usage/usageReports.js +208 -0
  112. package/dist/watchers/factory.d.ts +6 -0
  113. package/dist/watchers/factory.js +30 -25
  114. package/dist/zaiQuotaApi.d.ts +2 -0
  115. package/dist/zaiQuotaApi.js +2 -0
  116. package/package.json +1 -1
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * @module parsers/sessionActivityDetector
11
11
  */
12
+ import type { SessionEvent } from '../types/sessionEvent';
12
13
  export type SessionActivityState = 'ongoing' | 'ended' | 'stale';
13
14
  export interface SessionActivityResult {
14
15
  state: SessionActivityState;
@@ -17,6 +18,10 @@ export interface SessionActivityResult {
17
18
  /** Reason for the classification */
18
19
  reason: string;
19
20
  }
21
+ /** Grace period to prevent spinner flicker (ms) */
22
+ export declare const SESSION_ACTIVITY_GRACE_PERIOD_MS = 5000;
23
+ /** If mtime is older than this, session is stale regardless of content */
24
+ export declare const SESSION_ACTIVITY_STALENESS_MS: number;
20
25
  /**
21
26
  * Determines whether a session is ongoing, ended, or stale.
22
27
  *
@@ -29,9 +34,26 @@ export interface SessionActivityResult {
29
34
  * @returns Activity state with classification reason
30
35
  */
31
36
  export declare function detectSessionActivity(sessionPath: string): SessionActivityResult;
37
+ export interface ClassifySessionActivityOptions {
38
+ /** Canonical events already read for the session; only the tail is inspected. */
39
+ events: readonly SessionEvent[];
40
+ /** Source mtime; the last event's timestamp is used when unknown. */
41
+ mtimeMs: number | null | undefined;
42
+ /** Clock (default `Date.now()`). */
43
+ now?: number;
44
+ }
45
+ /**
46
+ * Classify a session from events already in memory, with the same states,
47
+ * reasons, grace period, and staleness rule as {@link detectSessionActivity},
48
+ * but without opening the file again. Works for every provider (the raw
49
+ * tail patterns only ever matched Claude Code JSONL) and for database-backed
50
+ * sessions that have no file to tail.
51
+ */
52
+ export declare function classifySessionActivity(options: ClassifySessionActivityOptions): SessionActivityResult;
32
53
  /**
33
54
  * Refresh a cached classification from its unchanged fingerprint without
34
55
  * re-reading the transcript. An active file remains active until it crosses
35
- * the same five-minute staleness boundary used by detectSessionActivity.
56
+ * the same five-minute staleness boundary used by detectSessionActivity,
57
+ * unless it was active only by grace period (`previousReason`).
36
58
  */
37
- export declare function refreshSessionActivityState(previous: 'active' | 'idle' | 'ended' | 'unknown', mtimeMs: number, nowMs?: number): 'active' | 'idle' | 'ended' | 'unknown';
59
+ export declare function refreshSessionActivityState(previous: 'active' | 'idle' | 'ended' | 'unknown', mtimeMs: number, nowMs?: number, previousReason?: string): 'active' | 'idle' | 'ended' | 'unknown';
@@ -44,16 +44,22 @@ var __importStar = (this && this.__importStar) || (function () {
44
44
  };
45
45
  })();
46
46
  Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.SESSION_ACTIVITY_STALENESS_MS = exports.SESSION_ACTIVITY_GRACE_PERIOD_MS = void 0;
47
48
  exports.detectSessionActivity = detectSessionActivity;
49
+ exports.classifySessionActivity = classifySessionActivity;
48
50
  exports.refreshSessionActivityState = refreshSessionActivityState;
49
51
  const fs = __importStar(require("fs"));
50
52
  // ── Constants ──
51
53
  /** How many bytes to read from the end of the file for analysis */
52
54
  const TAIL_BYTES = 32 * 1024;
53
55
  /** Grace period to prevent spinner flicker (ms) */
54
- const GRACE_PERIOD_MS = 5_000;
56
+ exports.SESSION_ACTIVITY_GRACE_PERIOD_MS = 5_000;
57
+ const GRACE_PERIOD_MS = exports.SESSION_ACTIVITY_GRACE_PERIOD_MS;
55
58
  /** If mtime is older than this, session is stale regardless of content */
56
- const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
59
+ exports.SESSION_ACTIVITY_STALENESS_MS = 5 * 60 * 1000;
60
+ const STALENESS_THRESHOLD_MS = exports.SESSION_ACTIVITY_STALENESS_MS;
61
+ /** How many trailing events the in-memory classifier inspects (about a 32 KiB tail). */
62
+ const TAIL_EVENTS = 200;
57
63
  // ── Event Classification Patterns ──
58
64
  /** Patterns indicating the AI is actively working */
59
65
  const AI_ACTIVITY_PATTERNS = [
@@ -106,7 +112,9 @@ function detectSessionActivity(sessionPath) {
106
112
  if (lines.length === 0) {
107
113
  return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'no-events' };
108
114
  }
109
- // Find the last line that matches each category
115
+ return classifyFromSignals(signalsFromLines(lines), stat.mtimeMs, mtimeAge);
116
+ }
117
+ function signalsFromLines(lines) {
110
118
  let lastAiActivityIndex = -1;
111
119
  let lastEndingIndex = -1;
112
120
  let hasTerminal = false;
@@ -136,38 +144,101 @@ function detectSessionActivity(sessionPath) {
136
144
  }
137
145
  }
138
146
  }
147
+ return { lastAiActivityIndex, lastEndingIndex, hasTerminal };
148
+ }
149
+ function hasToolResultBlock(event) {
150
+ const content = event.message?.content;
151
+ return (Array.isArray(content) &&
152
+ content.some((block) => typeof block === 'object' &&
153
+ block !== null &&
154
+ block.type === 'tool_result'));
155
+ }
156
+ /**
157
+ * The same signals the tail scanner derives from raw JSONL lines, read from
158
+ * canonical events instead: assistant, tool-use, and tool-result events (and
159
+ * user events carrying tool results) are AI activity; an assistant turn that
160
+ * stopped with `end_turn` or a user prompt is an ending event.
161
+ */
162
+ function signalsFromEvents(events) {
163
+ let lastAiActivityIndex = -1;
164
+ let lastEndingIndex = -1;
165
+ for (let i = 0; i < events.length; i++) {
166
+ const event = events[i];
167
+ const carriesToolResult = event.type === 'user' && hasToolResultBlock(event);
168
+ if (event.type === 'assistant' ||
169
+ event.type === 'tool_use' ||
170
+ event.type === 'tool_result' ||
171
+ carriesToolResult) {
172
+ lastAiActivityIndex = i;
173
+ }
174
+ if ((event.type === 'assistant' && event.message?.stop_reason === 'end_turn') ||
175
+ (event.type === 'user' && !carriesToolResult)) {
176
+ lastEndingIndex = i;
177
+ }
178
+ }
179
+ return { lastAiActivityIndex, lastEndingIndex, hasTerminal: false };
180
+ }
181
+ function classifyFromSignals(signals, mtimeMs, mtimeAge) {
182
+ const lastActivityTime = new Date(mtimeMs);
183
+ const { lastAiActivityIndex, lastEndingIndex, hasTerminal } = signals;
139
184
  // Terminal patterns mean the session is definitely done
140
185
  if (hasTerminal) {
141
- return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'terminal-event' };
186
+ return { state: 'ended', lastActivityTime, reason: 'terminal-event' };
142
187
  }
143
- // AI activity after the last ending event → ongoing
188
+ // AI activity after the last ending event -> ongoing
144
189
  if (lastAiActivityIndex > lastEndingIndex) {
145
- return {
146
- state: 'ongoing',
147
- lastActivityTime: new Date(stat.mtimeMs),
148
- reason: 'ai-activity-after-ending',
149
- };
190
+ return { state: 'ongoing', lastActivityTime, reason: 'ai-activity-after-ending' };
150
191
  }
151
192
  // Ending event with no subsequent AI activity
152
193
  if (lastEndingIndex >= 0) {
153
194
  // Apply grace period to prevent flicker
154
195
  if (mtimeAge < GRACE_PERIOD_MS) {
155
- return { state: 'ongoing', lastActivityTime: new Date(stat.mtimeMs), reason: 'grace-period' };
196
+ return { state: 'ongoing', lastActivityTime, reason: 'grace-period' };
156
197
  }
157
- return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'ending-event' };
198
+ return { state: 'ended', lastActivityTime, reason: 'ending-event' };
158
199
  }
159
- // No clear signal — if recently modified, assume ongoing
200
+ // No clear signal: if recently modified, assume ongoing
160
201
  if (mtimeAge < GRACE_PERIOD_MS) {
161
- return { state: 'ongoing', lastActivityTime: new Date(stat.mtimeMs), reason: 'recent-mtime' };
202
+ return { state: 'ongoing', lastActivityTime, reason: 'recent-mtime' };
203
+ }
204
+ return { state: 'ended', lastActivityTime, reason: 'no-activity-signal' };
205
+ }
206
+ /**
207
+ * Classify a session from events already in memory, with the same states,
208
+ * reasons, grace period, and staleness rule as {@link detectSessionActivity},
209
+ * but without opening the file again. Works for every provider (the raw
210
+ * tail patterns only ever matched Claude Code JSONL) and for database-backed
211
+ * sessions that have no file to tail.
212
+ */
213
+ function classifySessionActivity(options) {
214
+ const now = options.now ?? Date.now();
215
+ const events = options.events;
216
+ const lastEventMs = events.length > 0 ? Date.parse(events[events.length - 1].timestamp) : NaN;
217
+ const mtimeMs = typeof options.mtimeMs === 'number' && Number.isFinite(options.mtimeMs)
218
+ ? options.mtimeMs
219
+ : Number.isFinite(lastEventMs)
220
+ ? lastEventMs
221
+ : null;
222
+ if (mtimeMs === null) {
223
+ return { state: 'ended', lastActivityTime: null, reason: 'no-events' };
162
224
  }
163
- return { state: 'ended', lastActivityTime: new Date(stat.mtimeMs), reason: 'no-activity-signal' };
225
+ const mtimeAge = now - mtimeMs;
226
+ if (mtimeAge > STALENESS_THRESHOLD_MS) {
227
+ return { state: 'stale', lastActivityTime: new Date(mtimeMs), reason: 'mtime-stale' };
228
+ }
229
+ if (events.length === 0) {
230
+ return { state: 'ended', lastActivityTime: new Date(mtimeMs), reason: 'no-events' };
231
+ }
232
+ const tail = events.length > TAIL_EVENTS ? events.slice(-TAIL_EVENTS) : events;
233
+ return classifyFromSignals(signalsFromEvents(tail), mtimeMs, mtimeAge);
164
234
  }
165
235
  /**
166
236
  * Refresh a cached classification from its unchanged fingerprint without
167
237
  * re-reading the transcript. An active file remains active until it crosses
168
- * the same five-minute staleness boundary used by detectSessionActivity.
238
+ * the same five-minute staleness boundary used by detectSessionActivity,
239
+ * unless it was active only by grace period (`previousReason`).
169
240
  */
170
- function refreshSessionActivityState(previous, mtimeMs, nowMs = Date.now()) {
241
+ function refreshSessionActivityState(previous, mtimeMs, nowMs = Date.now(), previousReason) {
171
242
  if (!Number.isFinite(mtimeMs))
172
243
  return previous;
173
244
  if (nowMs - mtimeMs > STALENESS_THRESHOLD_MS)
@@ -176,6 +247,13 @@ function refreshSessionActivityState(previous, mtimeMs, nowMs = Date.now()) {
176
247
  return 'ended';
177
248
  if (previous === 'idle')
178
249
  return 'idle';
250
+ // A session that was only active thanks to the grace period ends once the
251
+ // grace period lapses, exactly as a fresh classification would say.
252
+ if (previous === 'active' &&
253
+ (previousReason === 'grace-period' || previousReason === 'recent-mtime') &&
254
+ nowMs - mtimeMs >= GRACE_PERIOD_MS) {
255
+ return 'ended';
256
+ }
179
257
  return previous === 'unknown' ? 'unknown' : 'active';
180
258
  }
181
259
  // ── Helpers ──
@@ -205,6 +205,17 @@ export declare function getAllProjectFolders(workspacePath?: string): ProjectFol
205
205
  * ```
206
206
  */
207
207
  export declare function findSessionsInDirectory(sessionDir: string): string[];
208
+ export interface SessionFileStat {
209
+ path: string;
210
+ mtime: Date;
211
+ sizeBytes: number;
212
+ }
213
+ /**
214
+ * The non-empty `.jsonl` files in a session directory with the stat each one
215
+ * already cost, newest first, so callers that need the mtime do not stat the
216
+ * same files a second time.
217
+ */
218
+ export declare function findSessionFilesWithStats(sessionDir: string): SessionFileStat[];
208
219
  /**
209
220
  * Detects if a workspace is a git worktree and resolves the main repo path.
210
221
  *
@@ -54,6 +54,7 @@ exports.findAllSessions = findAllSessions;
54
54
  exports.decodeEncodedPath = decodeEncodedPath;
55
55
  exports.getAllProjectFolders = getAllProjectFolders;
56
56
  exports.findSessionsInDirectory = findSessionsInDirectory;
57
+ exports.findSessionFilesWithStats = findSessionFilesWithStats;
57
58
  exports.resolveWorktreeMainRepo = resolveWorktreeMainRepo;
58
59
  exports.discoverWorktreeSiblings = discoverWorktreeSiblings;
59
60
  exports.findAllSessionsWithWorktrees = findAllSessionsWithWorktrees;
@@ -590,31 +591,33 @@ function getAllProjectFolders(workspacePath) {
590
591
  * ```
591
592
  */
592
593
  function findSessionsInDirectory(sessionDir) {
594
+ return findSessionFilesWithStats(sessionDir).map((file) => file.path);
595
+ }
596
+ /**
597
+ * The non-empty `.jsonl` files in a session directory with the stat each one
598
+ * already cost, newest first, so callers that need the mtime do not stat the
599
+ * same files a second time.
600
+ */
601
+ function findSessionFilesWithStats(sessionDir) {
593
602
  try {
594
603
  if (!fs.existsSync(sessionDir)) {
595
604
  return [];
596
605
  }
597
- const files = fs
606
+ return fs
598
607
  .readdirSync(sessionDir)
599
608
  .filter((file) => file.endsWith('.jsonl'))
600
609
  .map((file) => {
601
610
  const fullPath = path.join(sessionDir, file);
602
611
  try {
603
612
  const stats = fs.statSync(fullPath);
604
- return {
605
- path: fullPath,
606
- mtime: stats.mtime.getTime(),
607
- size: stats.size,
608
- };
613
+ return { path: fullPath, mtime: stats.mtime, sizeBytes: stats.size };
609
614
  }
610
615
  catch {
611
616
  return null;
612
617
  }
613
618
  })
614
- .filter((f) => f !== null && f.size > 0)
615
- .sort((a, b) => b.mtime - a.mtime)
616
- .map((f) => f.path);
617
- return files;
619
+ .filter((f) => f !== null && f.sizeBytes > 0)
620
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
618
621
  }
619
622
  catch {
620
623
  return [];
@@ -22,9 +22,34 @@ export interface PeakHoursState {
22
22
  updatedAt: string;
23
23
  unavailable: boolean;
24
24
  notApplicable?: boolean;
25
+ /**
26
+ * `promoclock` when the state came from promoclock.co, `schedule` when it
27
+ * was computed locally from the published weekday window. Absent on the
28
+ * not-applicable and unavailable placeholder states.
29
+ */
30
+ source?: 'promoclock' | 'schedule';
25
31
  }
26
- /** Default budget for the promoclock.co request, in milliseconds. */
27
- export declare const DEFAULT_PEAK_HOURS_TIMEOUT_MS = 10000;
32
+ /**
33
+ * Default budget for the promoclock.co request, in milliseconds. The schedule
34
+ * fallback answers the question anyway, so a slow third-party host should not
35
+ * hold up a `sidekick status` or `sidekick quota` run for long.
36
+ */
37
+ export declare const DEFAULT_PEAK_HOURS_TIMEOUT_MS = 4000;
38
+ /** A promoclock.co answer is reused for this long before the host is asked again. */
39
+ export declare const PEAK_HOURS_CACHE_MS: number;
40
+ /** Published peak window: weekdays 13:00–19:00 UTC. */
41
+ export declare const PEAK_WINDOW_START_UTC_HOUR = 13;
42
+ export declare const PEAK_WINDOW_END_UTC_HOUR = 19;
43
+ export declare const PEAK_HOURS_DESCRIPTION = "Weekdays 13:00\u201319:00 UTC";
44
+ /** Forget the cached promoclock.co answer. Test-only. */
45
+ export declare function _resetPeakHoursCache(): void;
46
+ /**
47
+ * Peak-hours state computed from the published schedule alone, with no
48
+ * network access. Used directly by cache-only commands (`sidekick today`) and
49
+ * as the fallback when promoclock.co cannot be reached, so every surface
50
+ * agrees on whether it is currently peak.
51
+ */
52
+ export declare function getScheduledPeakHoursState(now?: Date): PeakHoursState;
28
53
  /** Options for {@link fetchPeakHoursStatus}. */
29
54
  export interface FetchPeakHoursOptions {
30
55
  /**
@@ -32,6 +57,8 @@ export interface FetchPeakHoursOptions {
32
57
  * Defaults to {@link DEFAULT_PEAK_HOURS_TIMEOUT_MS}.
33
58
  */
34
59
  timeoutMs?: number;
60
+ /** Bypass the in-process cache and ask promoclock.co again. */
61
+ force?: boolean;
35
62
  }
36
63
  export declare function isClaudeCodeSessionProvider(providerId: ProviderId): boolean;
37
64
  export declare function createPeakHoursNotApplicableState(providerId: ProviderId): PeakHoursState;
@@ -40,7 +67,11 @@ export declare function scopePeakHoursToSessionProvider(providerId: ProviderId,
40
67
  * Fetch current Claude peak-hours state from promoclock.co.
41
68
  *
42
69
  * Single-shot — caller wraps in polling loop, EventEmitter, or interval.
43
- * Returns `unavailable: true` on any network, timeout, or parse error.
70
+ * A successful answer is cached in-process for {@link PEAK_HOURS_CACHE_MS}.
71
+ * On any network, timeout, or parse error the state is computed from the
72
+ * published schedule instead (`source: 'schedule'`), so callers always get a
73
+ * usable answer and every surface agrees on it; {@link unavailableState} is
74
+ * only returned when the schedule itself cannot be evaluated.
44
75
  *
45
76
  * promoclock.co is a third-party host, so the request is always bounded by
46
77
  * an abort timeout. Without one a hung host would stall the caller — and
package/dist/peakHours.js CHANGED
@@ -11,14 +11,74 @@
11
11
  * Polling / eventing is the caller's responsibility.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.DEFAULT_PEAK_HOURS_TIMEOUT_MS = void 0;
14
+ exports.PEAK_HOURS_DESCRIPTION = exports.PEAK_WINDOW_END_UTC_HOUR = exports.PEAK_WINDOW_START_UTC_HOUR = exports.PEAK_HOURS_CACHE_MS = exports.DEFAULT_PEAK_HOURS_TIMEOUT_MS = void 0;
15
+ exports._resetPeakHoursCache = _resetPeakHoursCache;
16
+ exports.getScheduledPeakHoursState = getScheduledPeakHoursState;
15
17
  exports.isClaudeCodeSessionProvider = isClaudeCodeSessionProvider;
16
18
  exports.createPeakHoursNotApplicableState = createPeakHoursNotApplicableState;
17
19
  exports.scopePeakHoursToSessionProvider = scopePeakHoursToSessionProvider;
18
20
  exports.fetchPeakHoursStatus = fetchPeakHoursStatus;
19
21
  const PROMOCLOCK_ENDPOINT = 'https://promoclock.co/api/status';
20
- /** Default budget for the promoclock.co request, in milliseconds. */
21
- exports.DEFAULT_PEAK_HOURS_TIMEOUT_MS = 10_000;
22
+ /**
23
+ * Default budget for the promoclock.co request, in milliseconds. The schedule
24
+ * fallback answers the question anyway, so a slow third-party host should not
25
+ * hold up a `sidekick status` or `sidekick quota` run for long.
26
+ */
27
+ exports.DEFAULT_PEAK_HOURS_TIMEOUT_MS = 4_000;
28
+ /** A promoclock.co answer is reused for this long before the host is asked again. */
29
+ exports.PEAK_HOURS_CACHE_MS = 10 * 60_000;
30
+ /** Published peak window: weekdays 13:00–19:00 UTC. */
31
+ exports.PEAK_WINDOW_START_UTC_HOUR = 13;
32
+ exports.PEAK_WINDOW_END_UTC_HOUR = 19;
33
+ exports.PEAK_HOURS_DESCRIPTION = 'Weekdays 13:00–19:00 UTC';
34
+ let cachedNetworkState = null;
35
+ /** Forget the cached promoclock.co answer. Test-only. */
36
+ function _resetPeakHoursCache() {
37
+ cachedNetworkState = null;
38
+ }
39
+ function isWeekdayUtc(date) {
40
+ const day = date.getUTCDay();
41
+ return day >= 1 && day <= 5;
42
+ }
43
+ function utcDateAtHour(date, hour, dayOffset = 0) {
44
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + dayOffset, hour));
45
+ }
46
+ /**
47
+ * Peak-hours state computed from the published schedule alone, with no
48
+ * network access. Used directly by cache-only commands (`sidekick today`) and
49
+ * as the fallback when promoclock.co cannot be reached, so every surface
50
+ * agrees on whether it is currently peak.
51
+ */
52
+ function getScheduledPeakHoursState(now = new Date()) {
53
+ const hour = now.getUTCHours() + now.getUTCMinutes() / 60;
54
+ const isPeak = isWeekdayUtc(now) && hour >= exports.PEAK_WINDOW_START_UTC_HOUR && hour < exports.PEAK_WINDOW_END_UTC_HOUR;
55
+ let nextChange;
56
+ if (isPeak) {
57
+ nextChange = utcDateAtHour(now, exports.PEAK_WINDOW_END_UTC_HOUR);
58
+ }
59
+ else if (isWeekdayUtc(now) && hour < exports.PEAK_WINDOW_START_UTC_HOUR) {
60
+ nextChange = utcDateAtHour(now, exports.PEAK_WINDOW_START_UTC_HOUR);
61
+ }
62
+ else {
63
+ let offset = 1;
64
+ while (!isWeekdayUtc(utcDateAtHour(now, 0, offset)))
65
+ offset += 1;
66
+ nextChange = utcDateAtHour(now, exports.PEAK_WINDOW_START_UTC_HOUR, offset);
67
+ }
68
+ return {
69
+ status: isPeak ? 'peak' : 'off_peak',
70
+ isPeak,
71
+ sessionLimitSpeed: isPeak ? 'faster' : 'normal',
72
+ label: isPeak ? 'Peak Hours' : 'Off-Peak',
73
+ peakHoursDescription: exports.PEAK_HOURS_DESCRIPTION,
74
+ nextChange: nextChange.toISOString(),
75
+ minutesUntilChange: Math.max(0, Math.round((nextChange.getTime() - now.getTime()) / 60_000)),
76
+ note: 'Computed from the published weekday schedule.',
77
+ updatedAt: now.toISOString(),
78
+ unavailable: false,
79
+ source: 'schedule',
80
+ };
81
+ }
22
82
  function unavailableState() {
23
83
  return {
24
84
  status: 'unknown',
@@ -76,13 +136,23 @@ function normalizeSpeed(raw) {
76
136
  * Fetch current Claude peak-hours state from promoclock.co.
77
137
  *
78
138
  * Single-shot — caller wraps in polling loop, EventEmitter, or interval.
79
- * Returns `unavailable: true` on any network, timeout, or parse error.
139
+ * A successful answer is cached in-process for {@link PEAK_HOURS_CACHE_MS}.
140
+ * On any network, timeout, or parse error the state is computed from the
141
+ * published schedule instead (`source: 'schedule'`), so callers always get a
142
+ * usable answer and every surface agrees on it; {@link unavailableState} is
143
+ * only returned when the schedule itself cannot be evaluated.
80
144
  *
81
145
  * promoclock.co is a third-party host, so the request is always bounded by
82
146
  * an abort timeout. Without one a hung host would stall the caller — and
83
147
  * this is polled, so the stall would repeat.
84
148
  */
85
149
  async function fetchPeakHoursStatus(options = {}) {
150
+ const now = Date.now();
151
+ if (!options.force &&
152
+ cachedNetworkState &&
153
+ now - cachedNetworkState.fetchedAt < exports.PEAK_HOURS_CACHE_MS) {
154
+ return cachedNetworkState.state;
155
+ }
86
156
  const timeoutMs = options.timeoutMs ?? exports.DEFAULT_PEAK_HOURS_TIMEOUT_MS;
87
157
  const controller = new AbortController();
88
158
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -90,12 +160,12 @@ async function fetchPeakHoursStatus(options = {}) {
90
160
  try {
91
161
  const res = await fetch(PROMOCLOCK_ENDPOINT, { signal: controller.signal });
92
162
  if (!res.ok)
93
- return unavailableState();
163
+ return scheduleFallback();
94
164
  const data = await res.json();
95
165
  const status = normalizeStatus(data.status);
96
166
  const sessionLimitSpeed = normalizeSpeed(data.sessionLimitSpeed);
97
167
  const isPeak = typeof data.isPeak === 'boolean' ? data.isPeak : status === 'peak';
98
- return {
168
+ const state = {
99
169
  status,
100
170
  isPeak,
101
171
  sessionLimitSpeed,
@@ -106,13 +176,28 @@ async function fetchPeakHoursStatus(options = {}) {
106
176
  note: data.note ?? '',
107
177
  updatedAt: data.timestamp ?? new Date().toISOString(),
108
178
  unavailable: false,
179
+ source: 'promoclock',
109
180
  };
181
+ cachedNetworkState = { state, fetchedAt: now };
182
+ return state;
110
183
  }
111
184
  catch {
112
185
  // Covers AbortError from the timeout above alongside network/parse failures.
113
- return unavailableState();
186
+ return scheduleFallback();
114
187
  }
115
188
  finally {
116
189
  clearTimeout(timeout);
117
190
  }
118
191
  }
192
+ function scheduleFallback() {
193
+ try {
194
+ const state = getScheduledPeakHoursState();
195
+ return {
196
+ ...state,
197
+ note: 'promoclock.co unreachable; computed from the published weekday schedule.',
198
+ };
199
+ }
200
+ catch {
201
+ return unavailableState();
202
+ }
203
+ }
@@ -34,6 +34,13 @@ export interface HydrateOptions {
34
34
  url?: string;
35
35
  /** Optional logger (useful for diagnostics without pulling a logger dep). */
36
36
  logger?: (msg: string) => void;
37
+ /**
38
+ * Never touch the network: apply the on-disk cache regardless of its age, or
39
+ * stay on the static tables when there is none. Callers that must produce
40
+ * the same figures on every run (scripted reports, tests, air-gapped hosts)
41
+ * set this; `SIDEKICK_OFFLINE=1` does the same for the CLI.
42
+ */
43
+ offline?: boolean;
37
44
  }
38
45
  export interface HydrateResult {
39
46
  /** Where the loaded pricing came from. */
@@ -60,6 +60,7 @@ const path = __importStar(require("node:path"));
60
60
  const modelContext_1 = require("./modelContext");
61
61
  const modelInfo_1 = require("./modelInfo");
62
62
  const paths_1 = require("./paths");
63
+ const atomic_1 = require("./writers/atomic");
63
64
  // ── Constants ──
64
65
  exports.LITELLM_CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
65
66
  const CACHE_FILE_NAME = 'pricing-catalog.json';
@@ -77,16 +78,16 @@ const DEFAULT_TIMEOUT_MS = 3_000;
77
78
  * Always resolves — never throws. Offline-safe.
78
79
  */
79
80
  async function hydratePricingCatalog(options = {}) {
80
- const { cacheDir = (0, paths_1.getConfigDir)(), fetchImpl = globalThis.fetch, ttlMs = DEFAULT_TTL_MS, timeoutMs = DEFAULT_TIMEOUT_MS, url = exports.LITELLM_CATALOG_URL, logger, } = options;
81
+ const { cacheDir = (0, paths_1.getConfigDir)(), fetchImpl = globalThis.fetch, ttlMs = DEFAULT_TTL_MS, timeoutMs = DEFAULT_TIMEOUT_MS, url = exports.LITELLM_CATALOG_URL, logger, offline = false, } = options;
81
82
  const cachePath = path.join(cacheDir, CACHE_FILE_NAME);
82
83
  const now = Date.now();
83
- // 1. Fresh cache?
84
+ // 1. Fresh cache? (Offline callers take any cache, however old.)
84
85
  const cached = await readCache(cachePath);
85
- if (cached && now - Date.parse(cached.fetchedAt) < ttlMs) {
86
+ if (cached && (offline || now - Date.parse(cached.fetchedAt) < ttlMs)) {
86
87
  return applyCacheFile(cached, 'cache');
87
88
  }
88
89
  // 2. Try network.
89
- if (fetchImpl) {
90
+ if (fetchImpl && !offline) {
90
91
  const fetched = await fetchCatalog(url, fetchImpl, timeoutMs, logger);
91
92
  if (fetched) {
92
93
  const payload = {
@@ -256,7 +257,9 @@ async function readCache(cachePath) {
256
257
  async function writeCache(cachePath, payload, logger) {
257
258
  try {
258
259
  await node_fs_1.promises.mkdir(path.dirname(cachePath), { recursive: true });
259
- await node_fs_1.promises.writeFile(cachePath, JSON.stringify(payload, null, 2), 'utf8');
260
+ // The CLI and the extension host can both refresh the catalog at once;
261
+ // the atomic writer keeps a reader from ever seeing a half-written file.
262
+ await (0, atomic_1.atomicWriteJson)(cachePath, payload);
260
263
  }
261
264
  catch (err) {
262
265
  logger?.(`pricingCatalog: failed to write cache: ${String(err)}`);