sidekick-shared 0.23.0 → 0.24.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 (90) hide show
  1. package/README.md +139 -57
  2. package/dist/accountManager.js +10 -0
  3. package/dist/aggregation/EventAggregator.d.ts +8 -1
  4. package/dist/aggregation/EventAggregator.js +82 -136
  5. package/dist/aggregation/snapshot.d.ts +1 -1
  6. package/dist/aggregation/snapshot.js +2 -1
  7. package/dist/aggregation/types.d.ts +5 -0
  8. package/dist/autoSwitch.d.ts +1 -1
  9. package/dist/autoSwitch.js +14 -3
  10. package/dist/browser.d.ts +6 -0
  11. package/dist/browser.js +10 -1
  12. package/dist/codexQuota.d.ts +2 -0
  13. package/dist/codexQuota.js +27 -0
  14. package/dist/codexQuotaWatcher.d.ts +7 -0
  15. package/dist/codexQuotaWatcher.js +37 -19
  16. package/dist/context/sessionContext.js +43 -15
  17. package/dist/credentialIO.js +19 -5
  18. package/dist/errorHistory.d.ts +1 -0
  19. package/dist/errorHistory.js +3 -2
  20. package/dist/extractors/tokenUsage.js +1 -1
  21. package/dist/extractors/toolCall.js +2 -2
  22. package/dist/index.d.ts +14 -4
  23. package/dist/index.js +25 -7
  24. package/dist/modelInfo.d.ts +17 -7
  25. package/dist/modelInfo.js +11 -2
  26. package/dist/multiProviderQuotaService.js +13 -2
  27. package/dist/node.d.ts +4 -0
  28. package/dist/node.js +8 -1
  29. package/dist/observedSessionCollector.d.ts +68 -0
  30. package/dist/observedSessionCollector.js +178 -0
  31. package/dist/parsers/codexParser.js +24 -6
  32. package/dist/parsers/openCodeParser.js +22 -1
  33. package/dist/parsers/sessionActivityDetector.js +2 -0
  34. package/dist/parsers/sessionPathResolver.js +19 -16
  35. package/dist/parsers/subagentScanner.d.ts +1 -1
  36. package/dist/parsers/subagentScanner.js +4 -5
  37. package/dist/parsers/subagentTraceParser.js +15 -12
  38. package/dist/providerStatus.js +13 -2
  39. package/dist/providers/claudeCode.js +63 -5
  40. package/dist/providers/codex.js +56 -17
  41. package/dist/providers/detect.js +32 -3
  42. package/dist/providers/openCodeDatabase.js +12 -3
  43. package/dist/quota.js +8 -0
  44. package/dist/quotaPoller.js +17 -6
  45. package/dist/quotaSnapshots.js +80 -26
  46. package/dist/readers/decisions.js +3 -1
  47. package/dist/readers/handoff.js +1 -1
  48. package/dist/readers/notes.js +9 -4
  49. package/dist/readers/plans.js +2 -4
  50. package/dist/readers/tasks.js +3 -1
  51. package/dist/report/htmlHelpers.js +6 -1
  52. package/dist/report/openBrowser.js +3 -3
  53. package/dist/report/transcriptParser.d.ts +3 -11
  54. package/dist/report/transcriptParser.js +69 -154
  55. package/dist/schemas/index.d.ts +7 -1
  56. package/dist/schemas/index.js +18 -2
  57. package/dist/schemas/observedSessionV1.d.ts +32 -1
  58. package/dist/schemas/observedSessionV1.js +11 -1
  59. package/dist/schemas/sessionEvent.d.ts +353 -12
  60. package/dist/schemas/sessionEvent.js +76 -14
  61. package/dist/schemas/tokenEstimation.d.ts +14 -0
  62. package/dist/schemas/tokenEstimation.js +10 -0
  63. package/dist/schemas/transcript.d.ts +24 -0
  64. package/dist/schemas/transcript.js +81 -0
  65. package/dist/schemas/usageNormalization.d.ts +55 -0
  66. package/dist/schemas/usageNormalization.js +41 -0
  67. package/dist/sessionTranscripts.d.ts +16 -0
  68. package/dist/sessionTranscripts.js +47 -0
  69. package/dist/statusline/formatter.js +8 -1
  70. package/dist/terminalSync.js +16 -7
  71. package/dist/tokenEstimation.d.ts +24 -0
  72. package/dist/tokenEstimation.js +62 -0
  73. package/dist/transcript.d.ts +92 -0
  74. package/dist/transcript.js +240 -0
  75. package/dist/types/historicalData.d.ts +14 -0
  76. package/dist/types/historicalData.js +22 -0
  77. package/dist/types/observedSessionV1.d.ts +3 -1
  78. package/dist/types/observedSessionV1.js +20 -6
  79. package/dist/types/sessionEvent.d.ts +66 -7
  80. package/dist/usageNormalization.d.ts +69 -0
  81. package/dist/usageNormalization.js +100 -0
  82. package/dist/watchers/factory.js +14 -3
  83. package/dist/watchers/jsonlTail.js +34 -14
  84. package/dist/watchers/jsonlWatcher.js +5 -3
  85. package/dist/watchers/providerReaderWatcher.js +4 -2
  86. package/dist/watchers/sqliteWatcher.d.ts +2 -0
  87. package/dist/watchers/sqliteWatcher.js +27 -14
  88. package/dist/writers/atomic.js +100 -13
  89. package/dist/zaiQuotaWatcher.js +6 -0
  90. package/package.json +1 -1
@@ -46,11 +46,14 @@ exports.ClaudeCodeProvider = void 0;
46
46
  const fs = __importStar(require("fs"));
47
47
  const os = __importStar(require("os"));
48
48
  const path = __importStar(require("path"));
49
+ const string_decoder_1 = require("string_decoder");
49
50
  const sessionContext_1 = require("../context/sessionContext");
50
51
  const jsonl_1 = require("../parsers/jsonl");
51
52
  const sessionPathResolver_1 = require("../parsers/sessionPathResolver");
52
53
  const subagentScanner_1 = require("../parsers/subagentScanner");
53
54
  const modelContext_1 = require("../modelContext");
55
+ const sessionEvent_1 = require("../schemas/sessionEvent");
56
+ const usageNormalization_1 = require("../usageNormalization");
54
57
  /** Type guard for content blocks with a `type` string property */
55
58
  function isTypedBlock(block) {
56
59
  return (block !== null &&
@@ -97,10 +100,15 @@ class ClaudeCodeReader {
97
100
  filePosition = 0;
98
101
  events = [];
99
102
  _wasTruncated = false;
103
+ decoder = new string_decoder_1.StringDecoder('utf8');
100
104
  constructor(sessionPath) {
101
105
  this.sessionPath = sessionPath;
102
106
  this.parser = new jsonl_1.JsonlParser({
103
- onEvent: (e) => this.events.push(e),
107
+ onEvent: (raw) => {
108
+ for (const event of (0, sessionEvent_1.extractSessionEvents)(raw)) {
109
+ this.events.push(normalizeClaudeUsage(event));
110
+ }
111
+ },
104
112
  onError: (_err, _line) => {
105
113
  // Silently skip parse errors — no logging framework dependency
106
114
  },
@@ -120,6 +128,7 @@ class ClaudeCodeReader {
120
128
  this._wasTruncated = true;
121
129
  this.filePosition = 0;
122
130
  this.parser.reset();
131
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
123
132
  }
124
133
  // No new content
125
134
  if (currentSize <= this.filePosition) {
@@ -129,11 +138,16 @@ class ClaudeCodeReader {
129
138
  const fd = fs.openSync(this.sessionPath, 'r');
130
139
  const bufferSize = currentSize - this.filePosition;
131
140
  const buffer = Buffer.alloc(bufferSize);
132
- fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
133
- fs.closeSync(fd);
134
- const chunk = buffer.toString('utf-8');
141
+ let bytesRead = 0;
142
+ try {
143
+ bytesRead = fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
144
+ }
145
+ finally {
146
+ fs.closeSync(fd);
147
+ }
148
+ const chunk = this.decoder.write(buffer.subarray(0, bytesRead));
135
149
  this.parser.processChunk(chunk);
136
- this.filePosition = currentSize;
150
+ this.filePosition += bytesRead;
137
151
  }
138
152
  catch (error) {
139
153
  console.error(`ClaudeCodeReader: error reading ${this.sessionPath}: ${error}`);
@@ -147,13 +161,18 @@ class ClaudeCodeReader {
147
161
  reset() {
148
162
  this.filePosition = 0;
149
163
  this.parser.reset();
164
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
150
165
  this._wasTruncated = false;
151
166
  }
152
167
  exists() {
153
168
  return fs.existsSync(this.sessionPath);
154
169
  }
155
170
  flush() {
171
+ const finalChunk = this.decoder.end();
172
+ if (finalChunk)
173
+ this.parser.processChunk(finalChunk);
156
174
  this.parser.flush();
175
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
157
176
  }
158
177
  getPosition() {
159
178
  return this.filePosition;
@@ -161,11 +180,50 @@ class ClaudeCodeReader {
161
180
  seekTo(position) {
162
181
  this.filePosition = position;
163
182
  this.parser.reset();
183
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
164
184
  }
165
185
  wasTruncated() {
166
186
  return this._wasTruncated;
167
187
  }
168
188
  }
189
+ function normalizeClaudeUsage(event) {
190
+ const message = event.message;
191
+ const usage = message?.usage;
192
+ if (!message || !usage) {
193
+ return {
194
+ ...event,
195
+ providerMetadata: {
196
+ ...event.providerMetadata,
197
+ providerId: 'claude-code',
198
+ source: 'claude-code-jsonl',
199
+ },
200
+ };
201
+ }
202
+ return {
203
+ ...event,
204
+ message: {
205
+ ...message,
206
+ normalizedUsage: (0, usageNormalization_1.normalizeProviderUsage)({
207
+ semantics: 'anthropic',
208
+ provider: 'anthropic',
209
+ source: 'claude-code-jsonl',
210
+ model: message.model,
211
+ inputTokens: usage.input_tokens,
212
+ outputTokens: usage.output_tokens,
213
+ cacheReadTokens: usage.cache_read_input_tokens,
214
+ cacheWriteTokens: usage.cache_creation_input_tokens,
215
+ reasoningTokens: usage.reasoning_tokens,
216
+ reasoningIncludedInOutput: false,
217
+ reportedCostUsd: usage.reported_cost,
218
+ }),
219
+ },
220
+ providerMetadata: {
221
+ ...event.providerMetadata,
222
+ providerId: 'claude-code',
223
+ source: 'claude-code-jsonl',
224
+ },
225
+ };
226
+ }
169
227
  /**
170
228
  * Session provider for Claude Code CLI.
171
229
  *
@@ -46,12 +46,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
46
46
  exports.CodexProvider = void 0;
47
47
  const fs = __importStar(require("fs"));
48
48
  const path = __importStar(require("path"));
49
+ const string_decoder_1 = require("string_decoder");
49
50
  const EventAggregator_1 = require("../aggregation/EventAggregator");
50
51
  const sessionContext_1 = require("../context/sessionContext");
51
52
  const codexParser_1 = require("../parsers/codexParser");
52
53
  const codexDatabase_1 = require("./codexDatabase");
53
54
  const codexProfiles_1 = require("../codexProfiles");
54
55
  const modelContext_1 = require("../modelContext");
56
+ const usageNormalization_1 = require("../usageNormalization");
55
57
  // ---------------------------------------------------------------------------
56
58
  // Helper functions
57
59
  // ---------------------------------------------------------------------------
@@ -131,6 +133,23 @@ function findRolloutFiles(dir) {
131
133
  }
132
134
  return results;
133
135
  }
136
+ function hasRolloutFile(dir) {
137
+ try {
138
+ if (!fs.existsSync(dir))
139
+ return false;
140
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
141
+ const fullPath = path.join(dir, entry.name);
142
+ if (entry.isDirectory() && hasRolloutFile(fullPath))
143
+ return true;
144
+ if (entry.isFile() && isRolloutFile(entry.name))
145
+ return true;
146
+ }
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ return false;
152
+ }
134
153
  function findRolloutFilesInConfiguredHomes() {
135
154
  const results = [];
136
155
  const seen = new Set();
@@ -148,7 +167,7 @@ function findRolloutFilesInConfiguredHomes() {
148
167
  function getSessionsDir() {
149
168
  const dirs = getSessionsDirs();
150
169
  for (const dir of dirs) {
151
- if (findRolloutFiles(dir).length > 0) {
170
+ if (hasRolloutFile(dir)) {
152
171
  return dir;
153
172
  }
154
173
  }
@@ -430,6 +449,7 @@ class CodexReader {
430
449
  filePosition = 0;
431
450
  lineBuffer = '';
432
451
  _wasTruncated = false;
452
+ decoder = new string_decoder_1.StringDecoder('utf8');
433
453
  constructor(rolloutPath, onContextWindowLimit, onRateLimits, onTokenUsage) {
434
454
  this.rolloutPath = rolloutPath;
435
455
  this.onContextWindowLimit = onContextWindowLimit;
@@ -455,6 +475,7 @@ class CodexReader {
455
475
  this.filePosition = 0;
456
476
  this.lineBuffer = '';
457
477
  this.parser.reset();
478
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
458
479
  }
459
480
  // No new content
460
481
  if (currentSize <= this.filePosition)
@@ -463,10 +484,15 @@ class CodexReader {
463
484
  const fd = fs.openSync(this.rolloutPath, 'r');
464
485
  const bufferSize = currentSize - this.filePosition;
465
486
  const buffer = Buffer.alloc(bufferSize);
466
- fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
467
- fs.closeSync(fd);
468
- const chunk = buffer.toString('utf-8');
469
- this.filePosition = currentSize;
487
+ let bytesRead = 0;
488
+ try {
489
+ bytesRead = fs.readSync(fd, buffer, 0, bufferSize, this.filePosition);
490
+ }
491
+ finally {
492
+ fs.closeSync(fd);
493
+ }
494
+ const chunk = this.decoder.write(buffer.subarray(0, bytesRead));
495
+ this.filePosition += bytesRead;
470
496
  // Process lines
471
497
  const text = this.lineBuffer + chunk;
472
498
  const lines = text.split('\n');
@@ -497,12 +523,22 @@ class CodexReader {
497
523
  // Propagate last token usage snapshot to the provider
498
524
  const lastUsage = this.parser.getLastTokenUsage();
499
525
  if (lastUsage && this.onTokenUsage) {
526
+ const normalized = (0, usageNormalization_1.normalizeProviderUsage)({
527
+ semantics: 'openai',
528
+ provider: 'openai',
529
+ source: 'codex-reader-snapshot',
530
+ model: this.parser.getCurrentModel() || undefined,
531
+ inputTokens: lastUsage.input_tokens,
532
+ outputTokens: lastUsage.output_tokens,
533
+ cacheReadTokens: lastUsage.cached_input_tokens,
534
+ reasoningTokens: lastUsage.reasoning_output_tokens,
535
+ });
500
536
  this.onTokenUsage({
501
- inputTokens: lastUsage.input_tokens || 0,
502
- outputTokens: lastUsage.output_tokens || 0,
503
- cacheWriteTokens: 0,
504
- cacheReadTokens: lastUsage.cached_input_tokens || 0,
505
- reasoningTokens: lastUsage.reasoning_output_tokens || 0,
537
+ inputTokens: normalized.uncachedInputTokens,
538
+ outputTokens: normalized.outputTokens,
539
+ cacheWriteTokens: normalized.cacheWriteTokens,
540
+ cacheReadTokens: normalized.cacheReadTokens,
541
+ reasoningTokens: normalized.reasoningTokens,
506
542
  model: this.parser.getCurrentModel() || 'unknown',
507
543
  timestamp: new Date(),
508
544
  });
@@ -521,12 +557,14 @@ class CodexReader {
521
557
  this.filePosition = 0;
522
558
  this.lineBuffer = '';
523
559
  this.parser.reset();
560
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
524
561
  this._wasTruncated = false;
525
562
  }
526
563
  exists() {
527
564
  return fs.existsSync(this.rolloutPath);
528
565
  }
529
566
  flush() {
567
+ this.lineBuffer += this.decoder.end();
530
568
  // Process any remaining data in the line buffer
531
569
  if (this.lineBuffer.trim()) {
532
570
  try {
@@ -537,6 +575,7 @@ class CodexReader {
537
575
  // Not a complete line yet
538
576
  }
539
577
  }
578
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
540
579
  }
541
580
  getPosition() {
542
581
  return this.filePosition;
@@ -544,6 +583,7 @@ class CodexReader {
544
583
  seekTo(position) {
545
584
  this.filePosition = position;
546
585
  this.lineBuffer = '';
586
+ this.decoder = new string_decoder_1.StringDecoder('utf8');
547
587
  }
548
588
  wasTruncated() {
549
589
  return this._wasTruncated;
@@ -675,9 +715,10 @@ class CodexProvider {
675
715
  const db = this.ensureDb();
676
716
  if (db) {
677
717
  const cwdStats = db.getAllDistinctCwds();
718
+ const sessionsDir = getSessionsDir();
678
719
  for (const stat of cwdStats) {
679
720
  seenCwds.set(stat.cwd, {
680
- dir: getSessionsDir(),
721
+ dir: sessionsDir,
681
722
  name: stat.cwd,
682
723
  encodedName: stat.cwd,
683
724
  sessionCount: stat.count,
@@ -837,7 +878,7 @@ class CodexProvider {
837
878
  }
838
879
  }
839
880
  // Accumulate tokens from usage events
840
- if (event.message.usage) {
881
+ if (event.message?.usage) {
841
882
  stats.inputTokens += event.message.usage.input_tokens || 0;
842
883
  stats.outputTokens += event.message.usage.output_tokens || 0;
843
884
  }
@@ -971,11 +1012,9 @@ class CodexProvider {
971
1012
  return this.lastTokenUsageData;
972
1013
  }
973
1014
  computeContextSize(usage) {
974
- // OpenAI's input_tokens already includes cached_input_tokens (it's a subset,
975
- // not additive like Anthropic's model). So inputTokens alone represents the
976
- // full context window usage. This differs from OpenCode's formula which adds
977
- // cacheWrite + cacheRead because Anthropic reports them separately.
978
- return usage.inputTokens;
1015
+ // Codex usage is normalized to the disjoint TokenUsage shape at the reader
1016
+ // boundary, so reconstruct the full prompt/context from uncached + cached.
1017
+ return usage.inputTokens + usage.cacheReadTokens;
979
1018
  }
980
1019
  // --- Rate limits (for vscode wrapper to build QuotaState) ---
981
1020
  /** Get the last rate limits data received from token_count events. */
@@ -100,6 +100,35 @@ function getMostRecentMtime(dir) {
100
100
  return 0;
101
101
  }
102
102
  }
103
+ function getMostRecentFileMtime(dir, isCandidate, depth) {
104
+ if (depth < 0)
105
+ return 0;
106
+ let latest = 0;
107
+ try {
108
+ for (const entry of fs.readdirSync(dir)) {
109
+ const filePath = path.join(dir, entry);
110
+ try {
111
+ const stats = fs.statSync(filePath);
112
+ if (stats.isDirectory?.()) {
113
+ latest = Math.max(latest, getMostRecentFileMtime(filePath, isCandidate, depth - 1));
114
+ }
115
+ else if (isCandidate(filePath)) {
116
+ latest = Math.max(latest, stats.mtime.getTime());
117
+ }
118
+ }
119
+ catch {
120
+ /* skip */
121
+ }
122
+ }
123
+ }
124
+ catch {
125
+ return 0;
126
+ }
127
+ return latest;
128
+ }
129
+ function getClaudeActivityMtime(claudeBase) {
130
+ return getMostRecentFileMtime(claudeBase, (filePath) => filePath.endsWith('.jsonl'), 2);
131
+ }
103
132
  function getOpenCodeActivityMtime() {
104
133
  const dataDir = (0, openCode_1.getOpenCodeDataDir)();
105
134
  const dbPath = path.join(dataDir, 'opencode.db');
@@ -123,7 +152,7 @@ function getCodexActivityMtime() {
123
152
  const dbMtime = getCodexStateDbMtime(codexHome);
124
153
  if (dbMtime > latest)
125
154
  latest = dbMtime;
126
- const sessionsMtime = getMostRecentMtime(path.join(codexHome, 'sessions'));
155
+ const sessionsMtime = getMostRecentFileMtime(path.join(codexHome, 'sessions'), (filePath) => path.basename(filePath).startsWith('rollout-') && filePath.endsWith('.jsonl'), 4);
127
156
  if (sessionsMtime > latest)
128
157
  latest = sessionsMtime;
129
158
  }
@@ -151,7 +180,7 @@ function getAllDetectedProviders() {
151
180
  const hasCodex = codexHomes.some((codexHome) => fs.existsSync(path.join(codexHome, 'sessions')) || hasCodexStateDb(codexHome));
152
181
  const available = [];
153
182
  if (hasClaude)
154
- available.push({ id: 'claude-code', mtime: getMostRecentMtime(claudeBase) });
183
+ available.push({ id: 'claude-code', mtime: getClaudeActivityMtime(claudeBase) });
155
184
  if (hasOpenCode)
156
185
  available.push({ id: 'opencode', mtime: getOpenCodeActivityMtime() });
157
186
  if (hasCodex)
@@ -172,7 +201,7 @@ function detectProvider(override) {
172
201
  const hasCodex = codexHomes.some((codexHome) => fs.existsSync(path.join(codexHome, 'sessions')) || hasCodexStateDb(codexHome));
173
202
  const available = [];
174
203
  if (hasClaude) {
175
- available.push({ id: 'claude-code', mtime: getMostRecentMtime(claudeBase) });
204
+ available.push({ id: 'claude-code', mtime: getClaudeActivityMtime(claudeBase) });
176
205
  }
177
206
  if (hasOpenCode) {
178
207
  available.push({ id: 'opencode', mtime: getOpenCodeActivityMtime() });
@@ -64,8 +64,12 @@ class OpenCodeDatabase {
64
64
  };
65
65
  }
66
66
  open() {
67
- if (this.runtimeStatus)
68
- return this.runtimeStatus.available;
67
+ if (this.runtimeStatus?.kind === 'available')
68
+ return true;
69
+ if (this.runtimeStatus &&
70
+ ['db_missing', 'sqlite_missing', 'sqlite_blocked'].includes(this.runtimeStatus.kind)) {
71
+ return false;
72
+ }
69
73
  if (!this.isAvailable()) {
70
74
  this.runtimeStatus = { available: false, kind: 'db_missing' };
71
75
  return false;
@@ -114,7 +118,12 @@ class OpenCodeDatabase {
114
118
  return JSON.parse(trimmed);
115
119
  }
116
120
  catch (error) {
117
- this.runtimeStatus = toRuntimeStatus(error, 'query_failed');
121
+ this.runtimeStatus = {
122
+ ...toRuntimeStatus(error, 'query_failed'),
123
+ // The database and sqlite binary were successfully opened. A failed
124
+ // query is transient and must not gate the next attempt.
125
+ available: true,
126
+ };
118
127
  return [];
119
128
  }
120
129
  }
package/dist/quota.js CHANGED
@@ -12,6 +12,7 @@ exports.withQuotaProjections = withQuotaProjections;
12
12
  exports.fetchQuota = fetchQuota;
13
13
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
14
14
  const BETA_HEADER = 'oauth-2025-04-20';
15
+ const FETCH_TIMEOUT_MS = 10_000;
15
16
  exports.FIVE_HOUR_WINDOW_MS = 5 * 3_600_000;
16
17
  exports.SEVEN_DAY_WINDOW_MS = 7 * 86_400_000;
17
18
  /**
@@ -94,9 +95,13 @@ function parseRetryAfterMs(retryAfter) {
94
95
  * @returns QuotaState with utilization data and elapsed-time projections
95
96
  */
96
97
  async function fetchQuota(accessToken) {
98
+ const controller = new AbortController();
99
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
100
+ timeout.unref();
97
101
  try {
98
102
  const res = await fetch(USAGE_URL, {
99
103
  method: 'GET',
104
+ signal: controller.signal,
100
105
  headers: {
101
106
  Authorization: `Bearer ${accessToken}`,
102
107
  'anthropic-beta': BETA_HEADER,
@@ -152,4 +157,7 @@ async function fetchQuota(accessToken) {
152
157
  catch {
153
158
  return unavailableState('Network error', { failureKind: 'network' });
154
159
  }
160
+ finally {
161
+ clearTimeout(timeout);
162
+ }
155
163
  }
@@ -111,10 +111,12 @@ class QuotaPoller {
111
111
  this.notify(state);
112
112
  }
113
113
  else if (state.failureKind === 'auth') {
114
- // Auth errors: stop polling, notify with error state
114
+ // Keep polling at the idle cadence so signing in again recovers
115
+ // without an extension/CLI restart.
115
116
  this.latest = state;
116
117
  this.notify(state);
117
- this.stop();
118
+ this.consecutiveFailures = 0;
119
+ this.scheduleNext(this.idleIntervalMs);
118
120
  return;
119
121
  }
120
122
  else {
@@ -134,17 +136,26 @@ class QuotaPoller {
134
136
  }
135
137
  }
136
138
  }
137
- catch {
139
+ catch (error) {
138
140
  this.consecutiveFailures++;
141
+ const state = {
142
+ fiveHour: { utilization: 0, resetsAt: '' },
143
+ sevenDay: { utilization: 0, resetsAt: '' },
144
+ available: false,
145
+ error: error instanceof Error ? error.message : String(error),
146
+ failureKind: 'auth',
147
+ };
148
+ this.latest = state;
149
+ this.notify(state);
139
150
  }
140
151
  this.scheduleNext();
141
152
  }
142
- scheduleNext() {
153
+ scheduleNext(overrideMs) {
143
154
  if (this.stopped)
144
155
  return;
145
156
  const baseInterval = this.isActive ? this.activeIntervalMs : this.idleIntervalMs;
146
- const backoff = Math.min(baseInterval * Math.pow(2, this.consecutiveFailures), this.maxBackoffMs);
147
- const delay = this.consecutiveFailures > 0 ? backoff : baseInterval;
157
+ const backoff = Math.min(baseInterval * Math.pow(2, this.consecutiveFailures), Math.max(this.maxBackoffMs, baseInterval));
158
+ const delay = overrideMs ?? (this.consecutiveFailures > 0 ? backoff : baseInterval);
148
159
  this.timer = setTimeout(() => void this.poll(), delay);
149
160
  }
150
161
  notify(state) {
@@ -40,6 +40,10 @@ const fs = __importStar(require("fs"));
40
40
  const path = __importStar(require("path"));
41
41
  const paths_1 = require("./paths");
42
42
  const codex_1 = require("./types/codex");
43
+ const LOCK_WAIT_MS = 15;
44
+ const LOCK_STALE_MS = 10_000;
45
+ const LOCK_TIMEOUT_MS = 15_000;
46
+ const lockSleepView = new Int32Array(new SharedArrayBuffer(4));
43
47
  function getQuotaSnapshotPath() {
44
48
  return path.join((0, paths_1.getConfigDir)(), 'quota-snapshots.json');
45
49
  }
@@ -84,6 +88,61 @@ function writeStore(store) {
84
88
  ensureConfigDir();
85
89
  atomicWriteJson(getQuotaSnapshotPath(), store);
86
90
  }
91
+ function withSnapshotStoreLock(operation) {
92
+ ensureConfigDir();
93
+ const lockPath = `${getQuotaSnapshotPath()}.lock`;
94
+ const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
95
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
96
+ while (true) {
97
+ try {
98
+ const descriptor = fs.openSync(lockPath, 'wx', 0o600);
99
+ fs.writeFileSync(descriptor, token, 'utf8');
100
+ fs.fsyncSync(descriptor);
101
+ fs.closeSync(descriptor);
102
+ break;
103
+ }
104
+ catch (error) {
105
+ if (error.code !== 'EEXIST')
106
+ throw error;
107
+ try {
108
+ const stat = fs.statSync(lockPath);
109
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
110
+ const owner = fs.readFileSync(lockPath, 'utf8');
111
+ const ownerPid = Number.parseInt(owner.split(':', 1)[0], 10);
112
+ let ownerAlive = Number.isInteger(ownerPid) && ownerPid > 0;
113
+ if (ownerAlive) {
114
+ try {
115
+ process.kill(ownerPid, 0);
116
+ }
117
+ catch (ownerError) {
118
+ ownerAlive = ownerError.code === 'EPERM';
119
+ }
120
+ }
121
+ if (!ownerAlive)
122
+ fs.rmSync(lockPath, { force: true });
123
+ }
124
+ }
125
+ catch {
126
+ // The owner may have released the lock between checks.
127
+ }
128
+ if (Date.now() >= deadline)
129
+ throw new Error(`Timed out waiting for quota lock: ${lockPath}`);
130
+ Atomics.wait(lockSleepView, 0, 0, LOCK_WAIT_MS);
131
+ }
132
+ }
133
+ try {
134
+ return operation();
135
+ }
136
+ finally {
137
+ try {
138
+ if (fs.readFileSync(lockPath, 'utf8') === token)
139
+ fs.rmSync(lockPath, { force: true });
140
+ }
141
+ catch {
142
+ // Never remove a lock that has already been released or replaced.
143
+ }
144
+ }
145
+ }
87
146
  function snapshotTimeMs(quota) {
88
147
  const capturedAt = quota.capturedAt ? Date.parse(quota.capturedAt) : NaN;
89
148
  return Number.isFinite(capturedAt) ? capturedAt : 0;
@@ -119,32 +178,27 @@ function shouldKeepExistingSnapshot(existing, next) {
119
178
  return snapshotTimeMs(existing) > snapshotTimeMs(next);
120
179
  }
121
180
  function writeQuotaSnapshot(providerId, accountId, quota) {
122
- const store = readStore();
123
- const index = store.snapshots.findIndex((item) => item.providerId === providerId && item.accountId === accountId);
124
- const existingQuota = index >= 0 ? store.snapshots[index].quota : undefined;
125
- const snapshot = {
126
- ...quota,
127
- providerId,
128
- capturedAt: quota.capturedAt ?? new Date().toISOString(),
129
- source: quota.source ?? 'session',
130
- stale: false,
131
- resetCredits: quota.resetCredits ?? (providerId === 'codex' ? existingQuota?.resetCredits : undefined),
132
- };
133
- if (index >= 0 && shouldKeepExistingSnapshot(store.snapshots[index].quota, snapshot)) {
134
- return;
135
- }
136
- const record = {
137
- providerId,
138
- accountId,
139
- quota: snapshot,
140
- };
141
- if (index >= 0) {
142
- store.snapshots[index] = record;
143
- }
144
- else {
145
- store.snapshots.push(record);
146
- }
147
- writeStore(store);
181
+ withSnapshotStoreLock(() => {
182
+ const store = readStore();
183
+ const index = store.snapshots.findIndex((item) => item.providerId === providerId && item.accountId === accountId);
184
+ const existingQuota = index >= 0 ? store.snapshots[index].quota : undefined;
185
+ const snapshot = {
186
+ ...quota,
187
+ providerId,
188
+ capturedAt: quota.capturedAt ?? new Date().toISOString(),
189
+ source: quota.source ?? 'session',
190
+ stale: false,
191
+ resetCredits: quota.resetCredits ?? (providerId === 'codex' ? existingQuota?.resetCredits : undefined),
192
+ };
193
+ if (index >= 0 && shouldKeepExistingSnapshot(store.snapshots[index].quota, snapshot))
194
+ return;
195
+ const record = { providerId, accountId, quota: snapshot };
196
+ if (index >= 0)
197
+ store.snapshots[index] = record;
198
+ else
199
+ store.snapshots.push(record);
200
+ writeStore(store);
201
+ });
148
202
  }
149
203
  function readQuotaSnapshot(providerId, accountId) {
150
204
  const store = readStore();
@@ -9,7 +9,9 @@ async function readDecisions(project, opts) {
9
9
  const store = await (0, helpers_1.readProjectJsonStore)(project, 'decisions');
10
10
  if (!store)
11
11
  return [];
12
- let decisions = Object.values(store.decisions);
12
+ let decisions = store.decisions && typeof store.decisions === 'object' && !Array.isArray(store.decisions)
13
+ ? Object.values(store.decisions)
14
+ : [];
13
15
  // Sort by timestamp descending
14
16
  decisions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
15
17
  if (opts?.search) {
@@ -42,7 +42,7 @@ const paths_1 = require("../paths");
42
42
  async function readLatestHandoff(project) {
43
43
  const candidates = typeof project === 'string' ? [project] : project.candidates;
44
44
  for (const slug of candidates) {
45
- const filePath = (0, paths_1.getProjectDataPath)(slug, 'handoffs').replace('.json', '-latest.md');
45
+ const filePath = (0, paths_1.getProjectDataPath)(slug, 'handoffs').replace(/\.json$/, '-latest.md');
46
46
  try {
47
47
  const content = await fs.promises.readFile(filePath, 'utf-8');
48
48
  if (content.trim())
@@ -10,21 +10,26 @@ async function readNotes(project, opts) {
10
10
  if (!store)
11
11
  return [];
12
12
  let notes = [];
13
+ const notesByFile = store.notesByFile && typeof store.notesByFile === 'object' && !Array.isArray(store.notesByFile)
14
+ ? store.notesByFile
15
+ : {};
13
16
  if (opts?.file) {
14
17
  // Normalize file path for comparison
15
18
  const normalizedFile = opts.file.replace(/\\/g, '/');
16
- for (const [filePath, fileNotes] of Object.entries(store.notesByFile)) {
19
+ for (const [filePath, fileNotes] of Object.entries(notesByFile)) {
17
20
  const normalizedKey = filePath.replace(/\\/g, '/');
18
21
  if (normalizedKey === normalizedFile ||
19
22
  normalizedKey.endsWith('/' + normalizedFile) ||
20
23
  normalizedFile.endsWith('/' + normalizedKey)) {
21
- notes.push(...fileNotes);
24
+ if (Array.isArray(fileNotes))
25
+ notes.push(...fileNotes);
22
26
  }
23
27
  }
24
28
  }
25
29
  else {
26
- for (const fileNotes of Object.values(store.notesByFile)) {
27
- notes.push(...fileNotes);
30
+ for (const fileNotes of Object.values(notesByFile)) {
31
+ if (Array.isArray(fileNotes))
32
+ notes.push(...fileNotes);
28
33
  }
29
34
  }
30
35
  if (opts?.type) {