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
@@ -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) {
@@ -48,6 +48,7 @@ const plan_1 = require("../types/plan");
48
48
  const paths_1 = require("../paths");
49
49
  const helpers_1 = require("./helpers");
50
50
  const planExtractor_1 = require("../parsers/planExtractor");
51
+ const atomic_1 = require("../writers/atomic");
51
52
  async function readPlans(slug, opts) {
52
53
  const filePath = (0, paths_1.getProjectDataPath)(slug, 'plans');
53
54
  const store = await (0, helpers_1.readJsonStore)(filePath);
@@ -170,10 +171,7 @@ async function writePlans(slug, plans) {
170
171
  plans: capped,
171
172
  lastSaved: new Date().toISOString(),
172
173
  };
173
- // Ensure directory exists
174
- const dir = filePath.replace(/[/\\][^/\\]+$/, '');
175
- await fs.promises.mkdir(dir, { recursive: true });
176
- await fs.promises.writeFile(filePath, JSON.stringify(store, null, 2), 'utf-8');
174
+ await (0, atomic_1.atomicWriteJson)(filePath, store);
177
175
  }
178
176
  /**
179
177
  * Reads raw plan markdown files from ~/.claude/plans/ that belong to
@@ -9,7 +9,9 @@ async function readTasks(project, opts) {
9
9
  const store = await (0, helpers_1.readProjectJsonStore)(project, 'tasks');
10
10
  if (!store)
11
11
  return [];
12
- let tasks = Object.values(store.tasks);
12
+ let tasks = store.tasks && typeof store.tasks === 'object' && !Array.isArray(store.tasks)
13
+ ? Object.values(store.tasks)
14
+ : [];
13
15
  const status = opts?.status ?? 'all';
14
16
  if (status !== 'all') {
15
17
  tasks = tasks.filter((t) => t.status === status);
@@ -53,7 +53,12 @@ function simpleMarkdownToHtml(text) {
53
53
  // Inline code (after escaping so backtick content is already safe)
54
54
  result = result.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
55
55
  // Links [text](url)
56
- result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
56
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
57
+ const trimmedUrl = url.trim();
58
+ if (!/^(?:https?:|mailto:|#|\/)/i.test(trimmedUrl))
59
+ return match;
60
+ return `<a href="${trimmedUrl}" target="_blank" rel="noopener">${label}</a>`;
61
+ });
57
62
  // Unordered lists
58
63
  result = result.replace(/^(\s*)[-*] (.+)$/gm, '$1<li>$2</li>');
59
64
  // Ordered lists
@@ -10,13 +10,13 @@ function openInBrowser(filePath) {
10
10
  const url = `file://${filePath}`;
11
11
  switch (process.platform) {
12
12
  case 'darwin':
13
- (0, child_process_1.exec)(`open "${url}"`);
13
+ (0, child_process_1.execFile)('open', [url]);
14
14
  break;
15
15
  case 'win32':
16
- (0, child_process_1.exec)(`start "" "${url}"`);
16
+ (0, child_process_1.execFile)('rundll32.exe', ['url.dll,FileProtocolHandler', url]);
17
17
  break;
18
18
  default:
19
- (0, child_process_1.exec)(`xdg-open "${url}"`);
19
+ (0, child_process_1.execFile)('xdg-open', [url]);
20
20
  break;
21
21
  }
22
22
  }
@@ -42,13 +42,20 @@ function estimateWindowEta(quota, now) {
42
42
  const resetAt = Date.parse(quota.fiveHour.resetsAt);
43
43
  if (!Number.isFinite(resetAt))
44
44
  return null;
45
+ const nowMs = now.getTime();
46
+ if (resetAt <= nowMs)
47
+ return null;
45
48
  const capturedAt = quota.capturedAt ? Date.parse(quota.capturedAt) : now.getTime();
46
49
  const sampleAt = Number.isFinite(capturedAt) ? capturedAt : now.getTime();
50
+ if (sampleAt >= resetAt)
51
+ return null;
47
52
  const elapsedMinutes = (sampleAt - (resetAt - quota_1.FIVE_HOUR_WINDOW_MS)) / 60_000;
48
53
  if (elapsedMinutes <= 0 || quota.fiveHour.utilization <= 0)
49
54
  return null;
50
55
  const burnRate = quota.fiveHour.utilization / elapsedMinutes;
51
- return (0, BurnRateCalculator_1.estimateTimeToQuota)(quota.fiveHour.utilization, 100, burnRate);
56
+ const eta = (0, BurnRateCalculator_1.estimateTimeToQuota)(quota.fiveHour.utilization, 100, burnRate);
57
+ const minutesUntilReset = (resetAt - nowMs) / 60_000;
58
+ return eta !== null && eta < minutesUntilReset ? eta : null;
52
59
  }
53
60
  function compactLabel(value) {
54
61
  const trimmed = value.trim();
@@ -107,20 +107,28 @@ function buildShellHookBlock() {
107
107
  function getShellRcPaths() {
108
108
  const zshrc = path.join(os.homedir(), '.zshrc');
109
109
  const bashrc = path.join(os.homedir(), '.bashrc');
110
- const paths = [zshrc];
111
- if (fs.existsSync(bashrc))
112
- paths.push(bashrc);
113
- return paths;
110
+ const existing = [zshrc, bashrc].filter((filePath) => fs.existsSync(filePath));
111
+ if (existing.length > 0)
112
+ return existing;
113
+ const shell = path.basename(process.env.SHELL ?? 'zsh');
114
+ return shell === 'bash' ? [bashrc] : [zshrc];
115
+ }
116
+ function resolveShellRcTarget(filePath) {
117
+ if (!fs.existsSync(filePath))
118
+ return { filePath, mode: 0o600 };
119
+ const realPath = fs.realpathSync(filePath);
120
+ return { filePath: realPath, mode: fs.statSync(realPath).mode & 0o777 };
114
121
  }
115
122
  function stripShellHook(content) {
116
123
  const pattern = new RegExp(`${HOOK_START}[\\s\\S]*?${HOOK_END}\\n?`, 'g');
117
124
  return content.replace(pattern, '').replace(/\n{3,}/g, '\n\n');
118
125
  }
119
126
  function installHookInFile(filePath) {
120
- const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
127
+ const target = resolveShellRcTarget(filePath);
128
+ const existing = fs.existsSync(target.filePath) ? fs.readFileSync(target.filePath, 'utf8') : '';
121
129
  const cleaned = stripShellHook(existing).replace(/\s*$/, '');
122
130
  const next = cleaned ? `${cleaned}\n\n${buildShellHookBlock()}` : buildShellHookBlock();
123
- atomicWriteFile(filePath, next, 0o600);
131
+ atomicWriteFile(target.filePath, next, target.mode);
124
132
  }
125
133
  function installShellHook() {
126
134
  for (const rcPath of getShellRcPaths()) {
@@ -130,7 +138,8 @@ function installShellHook() {
130
138
  function uninstallHookInFile(filePath) {
131
139
  if (!fs.existsSync(filePath))
132
140
  return;
133
- atomicWriteFile(filePath, stripShellHook(fs.readFileSync(filePath, 'utf8')), 0o600);
141
+ const target = resolveShellRcTarget(filePath);
142
+ atomicWriteFile(target.filePath, stripShellHook(fs.readFileSync(target.filePath, 'utf8')), target.mode);
134
143
  }
135
144
  function uninstallShellHook() {
136
145
  for (const rcPath of getShellRcPaths()) {
@@ -51,6 +51,13 @@ export interface DailyData {
51
51
  toolUsage: ToolUsageRecord[];
52
52
  updatedAt: string;
53
53
  }
54
+ export interface HourlyData {
55
+ hour: number;
56
+ tokens: TokenTotals;
57
+ totalCost: number;
58
+ messageCount: number;
59
+ sessionCount: number;
60
+ }
54
61
  export interface MonthlyData {
55
62
  month: string;
56
63
  tokens: TokenTotals;
@@ -75,6 +82,8 @@ export interface AllTimeStats {
75
82
  export interface HistoricalDataStore {
76
83
  schemaVersion: number;
77
84
  daily: Record<string, DailyData>;
85
+ /** Hourly buckets keyed by local YYYY-MM-DD. */
86
+ hourly?: Record<string, HourlyData[]>;
78
87
  monthly: Record<string, MonthlyData>;
79
88
  allTime: AllTimeStats;
80
89
  lastSaved: string;
@@ -92,6 +101,10 @@ export interface SessionHistoryRecord {
92
101
  tokens: TokenTotals;
93
102
  totalCost: number;
94
103
  messageCount: number;
104
+ /** Schema v3 additive fields used to make aggregate replacement idempotent. */
105
+ modelUsage?: ModelUsageRecord[];
106
+ toolUsage?: ToolUsageRecord[];
107
+ unpricedModelIds?: string[];
95
108
  qualityScore: number;
96
109
  qualityFactors: Array<{
97
110
  id: string;
@@ -132,3 +145,4 @@ export interface SessionSummary {
132
145
  costPerChangedLine?: number | null;
133
146
  }
134
147
  export declare function createEmptyTokenTotals(): TokenTotals;
148
+ export declare function createEmptyDataStore(): HistoricalDataStore;
@@ -18,6 +18,7 @@
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  exports.HISTORICAL_SESSION_RETENTION_LIMIT = exports.HISTORICAL_DATA_SCHEMA_VERSION = void 0;
20
20
  exports.createEmptyTokenTotals = createEmptyTokenTotals;
21
+ exports.createEmptyDataStore = createEmptyDataStore;
21
22
  exports.HISTORICAL_DATA_SCHEMA_VERSION = 3;
22
23
  exports.HISTORICAL_SESSION_RETENTION_LIMIT = 500;
23
24
  function createEmptyTokenTotals() {
@@ -28,3 +29,24 @@ function createEmptyTokenTotals() {
28
29
  cacheReadTokens: 0,
29
30
  };
30
31
  }
32
+ function createEmptyDataStore() {
33
+ const now = new Date().toISOString();
34
+ return {
35
+ schemaVersion: exports.HISTORICAL_DATA_SCHEMA_VERSION,
36
+ daily: {},
37
+ monthly: {},
38
+ sessions: [],
39
+ allTime: {
40
+ tokens: createEmptyTokenTotals(),
41
+ totalCost: 0,
42
+ messageCount: 0,
43
+ sessionCount: 0,
44
+ firstDate: '',
45
+ lastDate: '',
46
+ modelUsage: [],
47
+ toolUsage: [],
48
+ updatedAt: now,
49
+ },
50
+ lastSaved: now,
51
+ };
52
+ }
@@ -10,7 +10,7 @@ function evidence(provider, sessionId, sourcePath, event, eventIndex) {
10
10
  sessionId,
11
11
  sourcePath,
12
12
  eventIndex,
13
- eventId: event?.message.id,
13
+ eventId: event?.message?.id,
14
14
  timestamp: event?.timestamp,
15
15
  },
16
16
  ];
@@ -29,7 +29,7 @@ function derivePendingUserRequestV1(provider, sessionId, sourcePath, events) {
29
29
  const refs = evidence(provider, sessionId, sourcePath, event, index);
30
30
  return {
31
31
  schemaVersion: 1,
32
- id: `${provider}:${sessionId}:${request.id ?? event.message.id ?? index}`,
32
+ id: `${provider}:${sessionId}:${request.id ?? event.message?.id ?? index}`,
33
33
  provider,
34
34
  sessionId,
35
35
  kind: observed(request.kind, 'inferred', request.kind === 'question' ? 0.95 : 0.9, refs),
@@ -45,11 +45,12 @@ function derivePendingUserRequestV1(provider, sessionId, sourcePath, events) {
45
45
  }
46
46
  function waitingToolRequest(event) {
47
47
  const calls = [];
48
+ const message = event.message;
48
49
  if (event.type === 'tool_use') {
49
- calls.push({ id: event.message.id, name: event.tool?.name, input: event.tool?.input });
50
+ calls.push({ id: message?.id, name: event.tool?.name, input: event.tool?.input });
50
51
  }
51
- if (Array.isArray(event.message.content)) {
52
- for (const block of event.message.content) {
52
+ if (Array.isArray(message?.content)) {
53
+ for (const block of message.content) {
53
54
  if (!block || typeof block !== 'object')
54
55
  continue;
55
56
  const value = block;
@@ -221,6 +221,7 @@ export interface TimelineEvent {
221
221
  metadata?: {
222
222
  model?: string;
223
223
  toolName?: string;
224
+ toolUseId?: string;
224
225
  isError?: boolean;
225
226
  tokenCount?: number;
226
227
  fullText?: string;
@@ -56,14 +56,25 @@ function createWatcher(options) {
56
56
  }
57
57
  let sessionPath;
58
58
  if (sessionId) {
59
- const match = sessions.find((s) => s.includes(sessionId));
60
- if (!match) {
59
+ const matches = sessions.map((sessionPath) => ({
60
+ sessionPath,
61
+ id: provider.getSessionId(sessionPath),
62
+ }));
63
+ const exact = matches.filter((candidate) => candidate.id === sessionId);
64
+ const candidates = exact.length > 0 ? exact : matches.filter((candidate) => candidate.id.startsWith(sessionId));
65
+ if (candidates.length === 0) {
61
66
  throw new Error(`Session ${sessionId} not found. Available: ${sessions
62
67
  .slice(0, 5)
63
68
  .map((s) => path.basename(s))
64
69
  .join(', ')}`);
65
70
  }
66
- sessionPath = match;
71
+ if (candidates.length > 1) {
72
+ throw new Error(`Session ${sessionId} is ambiguous. Matches: ${candidates
73
+ .slice(0, 5)
74
+ .map((candidate) => candidate.id)
75
+ .join(', ')}`);
76
+ }
77
+ sessionPath = candidates[0].sessionPath;
67
78
  }
68
79
  else {
69
80
  sessionPath = sessions[0]; // most recent