sidekick-shared 0.23.0 → 0.23.1

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 (58) hide show
  1. package/README.md +24 -20
  2. package/dist/accountManager.js +10 -0
  3. package/dist/aggregation/EventAggregator.d.ts +4 -1
  4. package/dist/aggregation/EventAggregator.js +22 -9
  5. package/dist/aggregation/snapshot.d.ts +1 -1
  6. package/dist/aggregation/snapshot.js +2 -1
  7. package/dist/aggregation/types.d.ts +2 -0
  8. package/dist/autoSwitch.d.ts +1 -1
  9. package/dist/autoSwitch.js +14 -3
  10. package/dist/codexQuota.d.ts +2 -0
  11. package/dist/codexQuota.js +27 -0
  12. package/dist/codexQuotaWatcher.d.ts +7 -0
  13. package/dist/codexQuotaWatcher.js +37 -19
  14. package/dist/context/sessionContext.js +35 -9
  15. package/dist/credentialIO.js +19 -5
  16. package/dist/errorHistory.d.ts +1 -0
  17. package/dist/errorHistory.js +3 -2
  18. package/dist/extractors/toolCall.js +2 -2
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +9 -7
  21. package/dist/modelInfo.js +6 -0
  22. package/dist/multiProviderQuotaService.js +13 -2
  23. package/dist/parsers/codexParser.js +4 -1
  24. package/dist/parsers/sessionActivityDetector.js +2 -0
  25. package/dist/parsers/sessionPathResolver.js +19 -16
  26. package/dist/parsers/subagentScanner.d.ts +1 -1
  27. package/dist/parsers/subagentScanner.js +4 -5
  28. package/dist/parsers/subagentTraceParser.js +4 -7
  29. package/dist/providerStatus.js +13 -2
  30. package/dist/providers/claudeCode.js +18 -4
  31. package/dist/providers/codex.js +42 -13
  32. package/dist/providers/detect.js +32 -3
  33. package/dist/providers/openCodeDatabase.js +12 -3
  34. package/dist/quota.js +8 -0
  35. package/dist/quotaPoller.js +17 -6
  36. package/dist/quotaSnapshots.js +80 -26
  37. package/dist/readers/decisions.js +3 -1
  38. package/dist/readers/handoff.js +1 -1
  39. package/dist/readers/notes.js +9 -4
  40. package/dist/readers/plans.js +2 -4
  41. package/dist/readers/tasks.js +3 -1
  42. package/dist/report/htmlHelpers.js +6 -1
  43. package/dist/report/openBrowser.js +3 -3
  44. package/dist/statusline/formatter.js +8 -1
  45. package/dist/terminalSync.js +16 -7
  46. package/dist/types/historicalData.d.ts +14 -0
  47. package/dist/types/historicalData.js +22 -0
  48. package/dist/types/observedSessionV1.js +6 -5
  49. package/dist/types/sessionEvent.d.ts +1 -0
  50. package/dist/watchers/factory.js +14 -3
  51. package/dist/watchers/jsonlTail.js +34 -14
  52. package/dist/watchers/jsonlWatcher.js +5 -3
  53. package/dist/watchers/providerReaderWatcher.js +4 -2
  54. package/dist/watchers/sqliteWatcher.d.ts +2 -0
  55. package/dist/watchers/sqliteWatcher.js +27 -14
  56. package/dist/writers/atomic.js +100 -13
  57. package/dist/zaiQuotaWatcher.js +6 -0
  58. package/package.json +1 -1
package/README.md CHANGED
@@ -110,13 +110,23 @@ await hydratePricingCatalog({ cacheDir: '~/.config/sidekick' });
110
110
  ### Detect the active session provider
111
111
 
112
112
  ```typescript
113
- import { detectProvider } from 'sidekick-shared';
114
-
115
- const provider = await detectProvider('/path/to/project');
116
- if (provider) {
117
- console.log(`Active provider: ${provider.id}`);
118
- const sessions = await provider.listSessions();
119
- }
113
+ import {
114
+ detectProvider,
115
+ ClaudeCodeProvider,
116
+ OpenCodeProvider,
117
+ CodexProvider,
118
+ } from 'sidekick-shared';
119
+
120
+ const providerId = detectProvider(); // 'claude-code' | 'opencode' | 'codex'
121
+ const provider =
122
+ providerId === 'opencode'
123
+ ? new OpenCodeProvider()
124
+ : providerId === 'codex'
125
+ ? new CodexProvider()
126
+ : new ClaudeCodeProvider();
127
+
128
+ console.log(`Active provider: ${provider.displayName}`);
129
+ const sessions = provider.findAllSessions('/path/to/project');
120
130
  ```
121
131
 
122
132
  ### Read persisted tasks
@@ -237,17 +247,15 @@ The extractor is safe for CLI and VS Code extension-host code, but not for brows
237
247
  Build a provider-neutral snapshot of what an assistant has "seen" in a session — layered evidence sources, context pressure, and observed capabilities. Read it through any session provider, or build it directly from a canonical `SessionEvent[]`.
238
248
 
239
249
  ```typescript
240
- import { detectProvider, readSessionContextSnapshot } from 'sidekick-shared';
250
+ import { ClaudeCodeProvider, readSessionContextSnapshot } from 'sidekick-shared';
241
251
 
242
- const provider = await detectProvider('/path/to/project');
243
- if (provider) {
244
- const snapshot = readSessionContextSnapshot(provider, '/path/to/session.jsonl');
252
+ const provider = new ClaudeCodeProvider();
253
+ const snapshot = readSessionContextSnapshot(provider, '/path/to/session.jsonl');
245
254
 
246
- console.log(snapshot.pressure); // 'low' | 'medium' | 'high'
247
- console.log(snapshot.contextTokens, '/', snapshot.contextWindow);
248
- console.log(snapshot.capabilities.tools, snapshot.capabilities.mcpServers);
249
- console.log(snapshot.sources.length, 'evidence sources');
250
- }
255
+ console.log(snapshot.pressure); // 'low' | 'medium' | 'high'
256
+ console.log(snapshot.contextTokens, '/', snapshot.contextWindow);
257
+ console.log(snapshot.capabilities.tools, snapshot.capabilities.mcpServers);
258
+ console.log(snapshot.sources.length, 'evidence sources');
251
259
  ```
252
260
 
253
261
  Use `createSessionContextProjector()` for incremental updates as new events stream in, or `calculateSessionContextPressure(contextTokens, contextWindow)` for the pressure band alone.
@@ -403,10 +411,6 @@ const totalSource = mergeCostSources(a.source, b.source); // 'unpriced' wins (le
403
411
  console.log(formatCost(total), totalSource);
404
412
  ```
405
413
 
406
- ### Deferred Contextful adoption note
407
-
408
- `sidekick-shared@0.18.x` already exposes the quota primitives Contextful needs: `MultiProviderQuotaService`, `ProviderQuotaMap`, `ProviderQuotaState`, and `CodexQuotaWatcher`. Contextful should keep its local integration unchanged until a newer `sidekick-shared` release is published to npm, then migrate thin wrappers to these public APIs plus `formatTokenCount()`, `formatDurationMs()`, and `createJsonlTail()`.
409
-
410
414
  ## Building
411
415
 
412
416
  ```bash
@@ -281,6 +281,7 @@ async function spawnAccountLogin(provider, label, opts = {}) {
281
281
  }
282
282
  let childExited = false;
283
283
  let childExitCode = null;
284
+ const childState = {};
284
285
  const timeoutMs = opts.timeoutMs ?? 180_000;
285
286
  const deadline = Date.now() + timeoutMs;
286
287
  let child;
@@ -297,6 +298,10 @@ async function spawnAccountLogin(provider, label, opts = {}) {
297
298
  childExited = true;
298
299
  childExitCode = code;
299
300
  });
301
+ child.on('error', (error) => {
302
+ childExited = true;
303
+ childState.spawnError = error;
304
+ });
300
305
  while (true) {
301
306
  if (opts.signal?.aborted) {
302
307
  child.kill();
@@ -308,6 +313,11 @@ async function spawnAccountLogin(provider, label, opts = {}) {
308
313
  return finalizeAccountLogin(provider, begin.loginId, { activate: opts.activate ?? true });
309
314
  }
310
315
  if (childExited) {
316
+ if (childState.spawnError) {
317
+ const error = `Could not spawn account login: ${childState.spawnError.message}`;
318
+ emitStatus(opts, { state: 'failed', error });
319
+ return { success: false, error };
320
+ }
311
321
  const finalStatus = emitStatus(opts, getAccountLoginStatus(provider, begin.loginId));
312
322
  if (finalStatus.state === 'authenticated') {
313
323
  return finalizeAccountLogin(provider, begin.loginId, { activate: opts.activate ?? true });
@@ -13,6 +13,8 @@ import type { EventAggregatorOptions, AggregatedTokens, ModelUsageStats, BurnRat
13
13
  import type { SerializedFrequencyState } from './FrequencyTracker';
14
14
  import type { SerializedHeatmapState } from './HeatmapTracker';
15
15
  import type { SerializedPatternState } from './PatternExtractor';
16
+ /** Schema version for serialized snapshots. */
17
+ export declare const SNAPSHOT_SCHEMA_VERSION = 3;
16
18
  /**
17
19
  * JSON-serializable snapshot of EventAggregator state.
18
20
  * Used by session snapshot sidecar files for fast resume.
@@ -127,6 +129,7 @@ export declare class EventAggregator {
127
129
  private currentPermissionMode;
128
130
  private permissionModeHistory;
129
131
  private contextTimeline;
132
+ private readonly contextTimelineCap;
130
133
  private contextTurnIndex;
131
134
  private timeline;
132
135
  private pendingUserRequest;
@@ -166,7 +169,7 @@ export declare class EventAggregator {
166
169
  /** Serializes all mutable state to a JSON-safe object for snapshot persistence. */
167
170
  serialize(): SerializedAggregatorState;
168
171
  /** Restores mutable state from a serialized snapshot. Clears transient state (pending calls). */
169
- restore(state: SerializedAggregatorState): void;
172
+ restore(state: SerializedAggregatorState): boolean;
170
173
  private accumulateUsage;
171
174
  private updateBurnRate;
172
175
  private processLatency;
@@ -9,7 +9,7 @@
9
9
  * @module aggregation/EventAggregator
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.EventAggregator = void 0;
12
+ exports.EventAggregator = exports.SNAPSHOT_SCHEMA_VERSION = void 0;
13
13
  exports.parseTodoDependencies = parseTodoDependencies;
14
14
  const jsonl_1 = require("../parsers/jsonl");
15
15
  const planExtractor_1 = require("../parsers/planExtractor");
@@ -24,11 +24,12 @@ const errorTaxonomy_1 = require("../extractors/errorTaxonomy");
24
24
  // ── Defaults ──
25
25
  const DEFAULT_TIMELINE_CAP = 200;
26
26
  const DEFAULT_LATENCY_CAP = 100;
27
+ const DEFAULT_CONTEXT_TIMELINE_CAP = 2_000;
27
28
  const DEFAULT_BURN_WINDOW_MS = 5 * 60_000;
28
29
  const DEFAULT_BURN_SAMPLE_MS = 10_000;
29
30
  const COMPACTION_DROP_THRESHOLD = 0.8; // >20% drop
30
31
  /** Schema version for serialized snapshots. */
31
- const SNAPSHOT_SCHEMA_VERSION = 3;
32
+ exports.SNAPSHOT_SCHEMA_VERSION = 3;
32
33
  // ── Goal gate detection regex ──
33
34
  const GOAL_GATE_REGEX = /\b(CRITICAL|MUST|blocker|required|must.?complete|goal.?gate|essential|do.?not.?skip|blocking)\b/i;
34
35
  // ── Todo dependency parsing ──
@@ -127,6 +128,7 @@ class EventAggregator {
127
128
  permissionModeHistory = [];
128
129
  // Context timeline
129
130
  contextTimeline = [];
131
+ contextTimelineCap;
130
132
  contextTurnIndex = 0;
131
133
  // Timeline
132
134
  timeline = [];
@@ -150,6 +152,7 @@ class EventAggregator {
150
152
  this.latencyCap = options?.latencyCap ?? DEFAULT_LATENCY_CAP;
151
153
  this.burnWindowMs = options?.burnWindowMs ?? DEFAULT_BURN_WINDOW_MS;
152
154
  this.burnSampleMs = options?.burnSampleMs ?? DEFAULT_BURN_SAMPLE_MS;
155
+ this.contextTimelineCap = options?.contextTimelineCap ?? DEFAULT_CONTEXT_TIMELINE_CAP;
153
156
  this.computeContextSize = options?.computeContextSize ?? null;
154
157
  this.providerId = options?.providerId ?? null;
155
158
  this._providerId = this.providerId;
@@ -167,6 +170,10 @@ class EventAggregator {
167
170
  this.lastEventTime = event.timestamp;
168
171
  // Guard: some event types (e.g. 'summary') have no message field in the raw JSONL
169
172
  if (!event.message) {
173
+ if (event.type === 'summary') {
174
+ this.recordExplicitCompaction(event.timestamp, event.compaction);
175
+ this.addTimelineFromSessionEvent(event);
176
+ }
170
177
  return;
171
178
  }
172
179
  // 2. Track model
@@ -300,7 +307,7 @@ class EventAggregator {
300
307
  priced: true,
301
308
  };
302
309
  acc.calls++;
303
- acc.tokens += inputTok + outputTok;
310
+ acc.tokens += inputTok + outputTok + cacheWrite + cacheRead;
304
311
  acc.inputTokens += inputTok;
305
312
  acc.outputTokens += outputTok;
306
313
  acc.cacheWriteTokens += cacheWrite;
@@ -346,7 +353,7 @@ class EventAggregator {
346
353
  // 12. Gonzo/Lazyjournal analytics
347
354
  this.feedAnalytics(event.timestamp, event.summary, event.toolName);
348
355
  // Message count (skip system events)
349
- if (event.type !== 'system') {
356
+ if (event.type === 'user' || event.type === 'assistant') {
350
357
  this.messageCount++;
351
358
  }
352
359
  }
@@ -543,7 +550,7 @@ class EventAggregator {
543
550
  /** Serializes all mutable state to a JSON-safe object for snapshot persistence. */
544
551
  serialize() {
545
552
  return {
546
- version: SNAPSHOT_SCHEMA_VERSION,
553
+ version: exports.SNAPSHOT_SCHEMA_VERSION,
547
554
  tokens: {
548
555
  input: this.inputTokens,
549
556
  output: this.outputTokens,
@@ -587,8 +594,8 @@ class EventAggregator {
587
594
  }
588
595
  /** Restores mutable state from a serialized snapshot. Clears transient state (pending calls). */
589
596
  restore(state) {
590
- if (state.version !== SNAPSHOT_SCHEMA_VERSION) {
591
- return; // Incompatible snapshot — caller should fall back to full replay
597
+ if (state.version !== exports.SNAPSHOT_SCHEMA_VERSION) {
598
+ return false;
592
599
  }
593
600
  this.inputTokens = state.tokens.input;
594
601
  this.outputTokens = state.tokens.output;
@@ -617,7 +624,9 @@ class EventAggregator {
617
624
  this.permissionModeHistory = state.permissionModeHistory
618
625
  ? [...state.permissionModeHistory]
619
626
  : [];
620
- this.contextTimeline = state.contextTimeline ? [...state.contextTimeline] : [];
627
+ this.contextTimeline = state.contextTimeline
628
+ ? state.contextTimeline.slice(-this.contextTimelineCap)
629
+ : [];
621
630
  this.contextTurnIndex = state.contextTurnIndex ?? 0;
622
631
  this.timeline = [...state.timeline];
623
632
  this.messageCount = state.messageCount;
@@ -640,6 +649,7 @@ class EventAggregator {
640
649
  this.pendingSubagents.clear();
641
650
  this.pendingUserRequest = null;
642
651
  this.planExtractor.reset();
652
+ return true;
643
653
  }
644
654
  // ═══════════════════════════════════════════════════════════════════════
645
655
  // Private: Token & Context
@@ -716,6 +726,9 @@ class EventAggregator {
716
726
  inputTokens: contextSize,
717
727
  turnIndex: this.contextTurnIndex++,
718
728
  });
729
+ if (this.contextTimeline.length > this.contextTimelineCap) {
730
+ this.contextTimeline.splice(0, this.contextTimeline.length - this.contextTimelineCap);
731
+ }
719
732
  // Per-model usage. Accumulate reasoning tokens and an inverted-sticky
720
733
  // `priced` flag so one unpriced event taints the aggregate (UI renders "—").
721
734
  const perModelKey = model ?? this.currentModel ?? 'unknown';
@@ -731,7 +744,7 @@ class EventAggregator {
731
744
  priced: true,
732
745
  };
733
746
  acc.calls++;
734
- acc.tokens += inputTok + outputTok;
747
+ acc.tokens += inputTok + outputTok + cacheWrite + cacheRead;
735
748
  acc.inputTokens += inputTok;
736
749
  acc.outputTokens += outputTok;
737
750
  acc.cacheWriteTokens += cacheWrite;
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module aggregation/snapshot
9
9
  */
10
- import type { SerializedAggregatorState } from './EventAggregator';
10
+ import { type SerializedAggregatorState } from './EventAggregator';
11
11
  /** Snapshot sidecar file content. */
12
12
  export interface SessionSnapshot {
13
13
  /** Schema version for forward-compatibility checks. */
@@ -50,8 +50,9 @@ exports.isSnapshotValid = isSnapshotValid;
50
50
  const fs = __importStar(require("fs"));
51
51
  const path = __importStar(require("path"));
52
52
  const paths_1 = require("../paths");
53
+ const EventAggregator_1 = require("./EventAggregator");
53
54
  /** Current schema version — bump when format changes. */
54
- const SNAPSHOT_VERSION = 3;
55
+ const SNAPSHOT_VERSION = EventAggregator_1.SNAPSHOT_SCHEMA_VERSION;
55
56
  /** Returns the directory for snapshot files. */
56
57
  function getSnapshotsDir() {
57
58
  return path.join((0, paths_1.getConfigDir)(), 'snapshots');
@@ -31,6 +31,8 @@ export interface EventAggregatorOptions {
31
31
  timelineCap?: number;
32
32
  /** Maximum latency records to retain (default 100). */
33
33
  latencyCap?: number;
34
+ /** Maximum context-size points to retain (default 2000). */
35
+ contextTimelineCap?: number;
34
36
  /** Burn rate sliding window in ms (default 5 * 60_000). */
35
37
  burnWindowMs?: number;
36
38
  /** Burn rate sample interval in ms (default 10_000). */
@@ -42,7 +42,7 @@ export interface AutoSwitchControllerOptions {
42
42
  cooldownMs?: number;
43
43
  now?: () => number;
44
44
  }
45
- export declare function decideAutoSwitch(_provider: AccountProviderId, active: AutoSwitchActiveAccount, candidates: AutoSwitchCandidate[], config?: AutoSwitchConfig): AutoSwitchDecision | null;
45
+ export declare function decideAutoSwitch(_provider: AccountProviderId, active: AutoSwitchActiveAccount, candidates: AutoSwitchCandidate[], config?: AutoSwitchConfig, nowMs?: number): AutoSwitchDecision | null;
46
46
  export declare class AutoSwitchController implements Disposable {
47
47
  private readonly quotaService;
48
48
  private readonly getClaudeAccounts;
@@ -11,6 +11,7 @@ exports.DEFAULT_AUTO_SWITCH_CONFIG = {
11
11
  thresholdPct: 90,
12
12
  };
13
13
  const MATERIAL_REMAINING_IMPROVEMENT_PCT = 5;
14
+ const MAX_STALE_SNAPSHOT_AGE_MS = 5 * 60 * 60 * 1000;
14
15
  function mergedConfig(config) {
15
16
  return {
16
17
  ...exports.DEFAULT_AUTO_SWITCH_CONFIG,
@@ -27,7 +28,17 @@ function quotaRemaining(quota) {
27
28
  return 0;
28
29
  return Math.max(0, 100 - quotaUtilization(quota));
29
30
  }
30
- function decideAutoSwitch(_provider, active, candidates, config) {
31
+ function isUsableCandidateQuota(quota, nowMs) {
32
+ if (!quota?.available)
33
+ return false;
34
+ if (quota.stale !== true)
35
+ return true;
36
+ const capturedAt = Date.parse(quota.capturedAt ?? '');
37
+ if (!Number.isFinite(capturedAt) || capturedAt > nowMs)
38
+ return false;
39
+ return nowMs - capturedAt <= MAX_STALE_SNAPSHOT_AGE_MS;
40
+ }
41
+ function decideAutoSwitch(_provider, active, candidates, config, nowMs = Date.now()) {
31
42
  const cfg = mergedConfig(config);
32
43
  if (!cfg.enabled)
33
44
  return null;
@@ -40,7 +51,7 @@ function decideAutoSwitch(_provider, active, candidates, config) {
40
51
  const best = candidates
41
52
  .filter((candidate) => candidate.accountId !== active.accountId &&
42
53
  candidate.switchable !== false &&
43
- candidate.quota?.available)
54
+ isUsableCandidateQuota(candidate.quota, nowMs))
44
55
  .sort((a, b) => quotaRemaining(b.quota) - quotaRemaining(a.quota))[0];
45
56
  if (!best)
46
57
  return null;
@@ -129,7 +140,7 @@ class AutoSwitchController {
129
140
  quota: accountId === activeAccountId ? activeQuota : this.readSnapshot(providerId, accountId),
130
141
  };
131
142
  });
132
- const decision = decideAutoSwitch(providerId, { accountId: activeAccountId, quota: activeQuota }, candidates, this.config);
143
+ const decision = decideAutoSwitch(providerId, { accountId: activeAccountId, quota: activeQuota }, candidates, this.config, this.now());
133
144
  if (!decision)
134
145
  return;
135
146
  if (this.switchedDuringCrossing.has(providerId))
@@ -32,6 +32,8 @@ export interface CodexQuotaApiOptions {
32
32
  usageUrl?: string;
33
33
  resetCreditsUrl?: string;
34
34
  capturedAt?: string;
35
+ signal?: AbortSignal;
36
+ timeoutMs?: number;
35
37
  }
36
38
  export declare function quotaFromCodexRateLimits(rateLimits: CodexRateLimits | null | undefined, source?: 'api' | 'session' | 'cache', capturedAt?: string): QuotaState | null;
37
39
  export declare function readLatestCodexQuotaFromRollouts(sessionPaths: string[], options?: {
@@ -414,10 +414,19 @@ async function fetchCodexQuotaFromApi(options = {}) {
414
414
  sevenDayLabel: 'Secondary',
415
415
  };
416
416
  }
417
+ const controller = new AbortController();
418
+ const onAbort = () => controller.abort();
419
+ if (options.signal?.aborted)
420
+ controller.abort();
421
+ else
422
+ options.signal?.addEventListener('abort', onAbort, { once: true });
423
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
424
+ timeout.unref();
417
425
  try {
418
426
  const fetchImpl = options.fetchImpl ?? fetch;
419
427
  const response = await fetchImpl(options.usageUrl ?? CHATGPT_USAGE_URL, {
420
428
  method: 'GET',
429
+ signal: controller.signal,
421
430
  headers: {
422
431
  Authorization: `Bearer ${accessToken}`,
423
432
  Accept: 'application/json',
@@ -468,6 +477,7 @@ async function fetchCodexQuotaFromApi(options = {}) {
468
477
  accessToken,
469
478
  accountId: auth?.accountId,
470
479
  capturedAt,
480
+ signal: controller.signal,
471
481
  });
472
482
  if (resetCredits) {
473
483
  quota.resetCredits = resetCredits;
@@ -488,6 +498,10 @@ async function fetchCodexQuotaFromApi(options = {}) {
488
498
  sevenDayLabel: 'Secondary',
489
499
  };
490
500
  }
501
+ finally {
502
+ clearTimeout(timeout);
503
+ options.signal?.removeEventListener('abort', onAbort);
504
+ }
491
505
  }
492
506
  async function fetchCodexResetCreditsFromApi(options = {}) {
493
507
  const capturedAt = options.capturedAt ?? new Date().toISOString();
@@ -497,6 +511,14 @@ async function fetchCodexResetCreditsFromApi(options = {}) {
497
511
  const accessToken = auth?.accessToken;
498
512
  if (!accessToken)
499
513
  return undefined;
514
+ const controller = new AbortController();
515
+ const onAbort = () => controller.abort();
516
+ if (options.signal?.aborted)
517
+ controller.abort();
518
+ else
519
+ options.signal?.addEventListener('abort', onAbort, { once: true });
520
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000);
521
+ timeout.unref();
500
522
  try {
501
523
  const fetchImpl = options.fetchImpl ?? fetch;
502
524
  const headers = {
@@ -511,6 +533,7 @@ async function fetchCodexResetCreditsFromApi(options = {}) {
511
533
  const response = await fetchImpl(options.resetCreditsUrl ?? CHATGPT_RESET_CREDITS_URL, {
512
534
  method: 'GET',
513
535
  headers,
536
+ signal: controller.signal,
514
537
  });
515
538
  if (!response.ok)
516
539
  return undefined;
@@ -519,6 +542,10 @@ async function fetchCodexResetCreditsFromApi(options = {}) {
519
542
  catch {
520
543
  return undefined;
521
544
  }
545
+ finally {
546
+ clearTimeout(timeout);
547
+ options.signal?.removeEventListener('abort', onAbort);
548
+ }
522
549
  }
523
550
  function readCodexAuth(codexHome) {
524
551
  try {
@@ -15,6 +15,9 @@ export interface CodexQuotaWatcherOptions {
15
15
  discoveryPollIntervalMs?: number;
16
16
  maxTailBytes?: number;
17
17
  maxSessionFiles?: number;
18
+ /** Minimum interval between expensive rollout-tail fallback scans. */
19
+ localScanCacheMs?: number;
20
+ now?: () => number;
18
21
  providerFactory?: () => CodexProvider;
19
22
  getActiveAccount?: CodexAccountReader;
20
23
  readSnapshot?: SnapshotReader;
@@ -39,6 +42,8 @@ export declare class CodexQuotaWatcher implements Disposable {
39
42
  private readonly watchFile;
40
43
  private readonly maxTailBytes;
41
44
  private readonly maxSessionFiles;
45
+ private readonly localScanCacheMs;
46
+ private readonly now;
42
47
  private readonly workspaceId;
43
48
  private readonly appendHistorySample;
44
49
  private readonly listeners;
@@ -48,6 +53,8 @@ export declare class CodexQuotaWatcher implements Disposable {
48
53
  private fileWatcher;
49
54
  private sessionPath;
50
55
  private lastEmissionKey;
56
+ private lastLocalScanAt;
57
+ private lastLocalScanState;
51
58
  private running;
52
59
  constructor(workspacePath: string, options?: CodexQuotaWatcherOptions);
53
60
  start(): void;
@@ -41,6 +41,7 @@ const quotaSnapshots_1 = require("./quotaSnapshots");
41
41
  const quotaHistory_1 = require("./quotaHistory");
42
42
  const codex_1 = require("./providers/codex");
43
43
  const DEFAULT_DISCOVERY_POLL_INTERVAL_MS = 30_000;
44
+ const DEFAULT_LOCAL_SCAN_CACHE_MS = 5 * 60_000;
44
45
  function accountEmail(account) {
45
46
  return account?.email ?? account?.metadata?.email;
46
47
  }
@@ -81,6 +82,8 @@ class CodexQuotaWatcher {
81
82
  watchFile;
82
83
  maxTailBytes;
83
84
  maxSessionFiles;
85
+ localScanCacheMs;
86
+ now;
84
87
  workspaceId;
85
88
  appendHistorySample;
86
89
  listeners = [];
@@ -90,6 +93,8 @@ class CodexQuotaWatcher {
90
93
  fileWatcher = null;
91
94
  sessionPath = null;
92
95
  lastEmissionKey = null;
96
+ lastLocalScanAt = Number.NEGATIVE_INFINITY;
97
+ lastLocalScanState = null;
93
98
  running = false;
94
99
  constructor(workspacePath, options = {}) {
95
100
  this.workspacePath = workspacePath;
@@ -110,6 +115,8 @@ class CodexQuotaWatcher {
110
115
  this.watchFile = options.watchFile ?? fs.watch;
111
116
  this.maxTailBytes = options.maxTailBytes;
112
117
  this.maxSessionFiles = options.maxSessionFiles;
118
+ this.localScanCacheMs = options.localScanCacheMs ?? DEFAULT_LOCAL_SCAN_CACHE_MS;
119
+ this.now = options.now ?? Date.now;
113
120
  this.workspaceId = options.workspaceId;
114
121
  this.appendHistorySample = options.appendHistorySample ?? quotaHistory_1.appendQuotaHistorySample;
115
122
  }
@@ -255,28 +262,39 @@ class CodexQuotaWatcher {
255
262
  }
256
263
  emitCachedOrUnavailable() {
257
264
  const account = this.getActiveAccount();
258
- let localProvider = null;
259
- try {
260
- localProvider = this.providerFactory();
261
- const local = (0, codexQuota_1.resolveCodexQuotaFromLocalSources)({
262
- workspacePath: this.workspacePath,
263
- activeAccount: account,
264
- readSnapshot: this.readSnapshot,
265
- writeSnapshot: this.writeSnapshot,
266
- provider: localProvider,
267
- maxTailBytes: this.maxTailBytes,
268
- maxSessionFiles: this.maxSessionFiles,
269
- });
270
- if (local) {
271
- this.emitState(local);
265
+ const scanNow = this.now();
266
+ if (scanNow - this.lastLocalScanAt < this.localScanCacheMs) {
267
+ if (this.lastLocalScanState) {
268
+ this.emitState(this.lastLocalScanState);
272
269
  return;
273
270
  }
274
271
  }
275
- catch {
276
- // Fall through to account-scoped cache or unavailable state.
277
- }
278
- finally {
279
- localProvider?.dispose();
272
+ else {
273
+ let localProvider = null;
274
+ this.lastLocalScanAt = scanNow;
275
+ this.lastLocalScanState = null;
276
+ try {
277
+ localProvider = this.providerFactory();
278
+ this.lastLocalScanState = (0, codexQuota_1.resolveCodexQuotaFromLocalSources)({
279
+ workspacePath: this.workspacePath,
280
+ activeAccount: account,
281
+ readSnapshot: this.readSnapshot,
282
+ writeSnapshot: this.writeSnapshot,
283
+ provider: localProvider,
284
+ maxTailBytes: this.maxTailBytes,
285
+ maxSessionFiles: this.maxSessionFiles,
286
+ });
287
+ if (this.lastLocalScanState) {
288
+ this.emitState(this.lastLocalScanState);
289
+ return;
290
+ }
291
+ }
292
+ catch {
293
+ // Fall through to account-scoped cache or unavailable state.
294
+ }
295
+ finally {
296
+ localProvider?.dispose();
297
+ }
280
298
  }
281
299
  const cached = account ? this.readSnapshot('codex', account.id) : null;
282
300
  if (cached) {
@@ -57,6 +57,10 @@ function buildSessionContextSnapshot(events, options = {}) {
57
57
  if (event.rateLimits)
58
58
  lastRateLimits = event.rateLimits;
59
59
  }
60
+ return assembleSessionContextSnapshot(aggregator, sourceState, lastPermissionMode, lastRateLimits, options);
61
+ }
62
+ function assembleSessionContextSnapshot(aggregator, sourceState, lastPermissionMode, lastRateLimits, options) {
63
+ const providerId = options.providerId;
60
64
  const metrics = aggregator.getMetrics();
61
65
  const model = options.model ?? metrics.currentModel ?? metrics.modelStats[0]?.model;
62
66
  const contextWindow = options.contextWindow ??
@@ -100,24 +104,46 @@ function buildSessionContextSnapshot(events, options = {}) {
100
104
  };
101
105
  }
102
106
  function createSessionContextProjector(options = {}) {
103
- let events = [];
107
+ let aggregator = createProjectorAggregator(options);
108
+ let sourceState = createSourceExtractionState(options);
109
+ let lastPermissionMode;
110
+ let lastRateLimits;
111
+ const process = (event) => {
112
+ aggregator.processEvent(event);
113
+ extractSourcesFromEvent(event, sourceState);
114
+ if (event.permissionMode)
115
+ lastPermissionMode = event.permissionMode;
116
+ if (event.rateLimits)
117
+ lastRateLimits = event.rateLimits;
118
+ };
119
+ const snapshot = () => assembleSessionContextSnapshot(aggregator, sourceState, lastPermissionMode, lastRateLimits, options);
104
120
  return {
105
121
  processEvent(event) {
106
- events.push(event);
107
- return buildSessionContextSnapshot(events, options);
122
+ process(event);
123
+ return snapshot();
108
124
  },
109
125
  processEvents(newEvents) {
110
- events.push(...newEvents);
111
- return buildSessionContextSnapshot(events, options);
126
+ for (const event of newEvents)
127
+ process(event);
128
+ return snapshot();
112
129
  },
113
130
  getSnapshot() {
114
- return buildSessionContextSnapshot(events, options);
131
+ return snapshot();
115
132
  },
116
133
  reset() {
117
- events = [];
134
+ aggregator = createProjectorAggregator(options);
135
+ sourceState = createSourceExtractionState(options);
136
+ lastPermissionMode = undefined;
137
+ lastRateLimits = undefined;
118
138
  },
119
139
  };
120
140
  }
141
+ function createProjectorAggregator(options) {
142
+ return new EventAggregator_1.EventAggregator({
143
+ providerId: options.providerId,
144
+ computeContextSize: options.computeContextSize,
145
+ });
146
+ }
121
147
  function readSessionContextSnapshot(provider, sessionPath, options = {}) {
122
148
  const reader = provider.createReader(sessionPath);
123
149
  const events = reader.readAll();
@@ -177,7 +203,7 @@ function extractSourcesFromEvent(event, state) {
177
203
  layer: 'summary',
178
204
  sourceType: 'summary',
179
205
  title: 'Context summary',
180
- text: extractText(event.message.content) || 'Context compacted',
206
+ text: extractText(event.message?.content) || 'Context compacted',
181
207
  });
182
208
  break;
183
209
  }
@@ -394,7 +420,7 @@ function limitSources(sources, limit) {
394
420
  }
395
421
  function latestModel(events) {
396
422
  for (let i = events.length - 1; i >= 0; i--) {
397
- const model = events[i].message.model;
423
+ const model = events[i].message?.model;
398
424
  if (model)
399
425
  return model;
400
426
  }
@@ -52,6 +52,12 @@ const os = __importStar(require("os"));
52
52
  const child_process_1 = require("child_process");
53
53
  const claudeProfiles_1 = require("./claudeProfiles");
54
54
  exports.KEYCHAIN_SERVICE = 'Claude Code-credentials';
55
+ function quoteSecurityInteractiveArg(value) {
56
+ // `security -i` is not a POSIX shell: adjacent quoted segments do NOT
57
+ // concatenate (so the shell-style '\'' splice splits the token), and
58
+ // backslash escapes the next character even inside single quotes.
59
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, `\\'`)}'`;
60
+ }
55
61
  function getCredentialsFilePath(configDir) {
56
62
  return path.join(configDir ?? path.join(os.homedir(), '.claude'), '.credentials.json');
57
63
  }
@@ -93,16 +99,24 @@ function writeActiveCredentials(credentials, configDir) {
93
99
  const json = JSON.stringify(credentials);
94
100
  JSON.parse(json); // validate round-trip
95
101
  if (process.platform === 'darwin') {
96
- (0, child_process_1.execFileSync)('security', [
102
+ const service = (0, claudeProfiles_1.claudeKeychainService)(configDir);
103
+ const account = process.env.USER || 'user';
104
+ const command = [
97
105
  'add-generic-password',
98
106
  '-U',
99
107
  '-s',
100
- (0, claudeProfiles_1.claudeKeychainService)(configDir),
108
+ quoteSecurityInteractiveArg(service),
101
109
  '-a',
102
- process.env.USER || 'user',
110
+ quoteSecurityInteractiveArg(account),
103
111
  '-w',
104
- json,
105
- ], { stdio: ['pipe', 'pipe', 'pipe'], timeout: 4000, killSignal: 'SIGKILL' });
112
+ quoteSecurityInteractiveArg(json),
113
+ ].join(' ');
114
+ (0, child_process_1.execFileSync)('security', ['-i'], {
115
+ input: command + '\n',
116
+ stdio: ['pipe', 'pipe', 'pipe'],
117
+ timeout: 4000,
118
+ killSignal: 'SIGKILL',
119
+ });
106
120
  return;
107
121
  }
108
122
  // Linux / WSL / Windows — file-based