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
@@ -48,7 +48,11 @@ function createJsonlTail(options) {
48
48
  class JsonlTailReader {
49
49
  options;
50
50
  active = false;
51
- offset;
51
+ /** Physical byte position already read from the file. */
52
+ readOffset;
53
+ /** Resume-safe byte position ending immediately after a newline. */
54
+ committedOffset;
55
+ pendingBytes = Buffer.alloc(0);
52
56
  fsWatcher = null;
53
57
  debounceTimer = null;
54
58
  catchupTimer = null;
@@ -56,7 +60,8 @@ class JsonlTailReader {
56
60
  parser;
57
61
  constructor(options) {
58
62
  this.options = options;
59
- this.offset = options.startOffset ?? 0;
63
+ this.readOffset = options.startOffset ?? 0;
64
+ this.committedOffset = this.readOffset;
60
65
  this.parser = new jsonl_1.JsonlParser({
61
66
  onEvent: (event) => {
62
67
  this.eventsInCurrentBatch += 1;
@@ -74,11 +79,12 @@ class JsonlTailReader {
74
79
  this.active = true;
75
80
  if (this.options.startAtEnd && this.options.startOffset === undefined) {
76
81
  try {
77
- this.offset = fs.statSync(this.options.path).size;
82
+ this.readOffset = fs.statSync(this.options.path).size;
78
83
  }
79
84
  catch {
80
- this.offset = 0;
85
+ this.readOffset = 0;
81
86
  }
87
+ this.committedOffset = this.readOffset;
82
88
  }
83
89
  this.readNow();
84
90
  this.watchFile();
@@ -112,27 +118,39 @@ class JsonlTailReader {
112
118
  let fd = null;
113
119
  try {
114
120
  const stat = fs.statSync(this.options.path);
115
- if (stat.size < this.offset) {
116
- this.offset = 0;
121
+ if (stat.size < this.readOffset) {
122
+ this.readOffset = 0;
123
+ this.committedOffset = 0;
124
+ this.pendingBytes = Buffer.alloc(0);
117
125
  this.parser.reset();
118
126
  }
119
- if (stat.size <= this.offset)
127
+ if (stat.size <= this.readOffset)
120
128
  return;
121
- const bytesToRead = stat.size - this.offset;
129
+ const bytesToRead = stat.size - this.readOffset;
122
130
  const buffer = Buffer.alloc(bytesToRead);
123
131
  fd = fs.openSync(this.options.path, 'r');
124
- const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, this.offset);
132
+ const bytesRead = fs.readSync(fd, buffer, 0, bytesToRead, this.readOffset);
125
133
  fs.closeSync(fd);
126
134
  fd = null;
127
135
  if (bytesRead <= 0)
128
136
  return;
129
- this.offset += bytesRead;
137
+ this.readOffset += bytesRead;
130
138
  this.eventsInCurrentBatch = 0;
131
- this.parser.processChunk(buffer.toString('utf-8', 0, bytesRead));
139
+ const combined = Buffer.concat([this.pendingBytes, buffer.subarray(0, bytesRead)]);
140
+ const lastNewline = combined.lastIndexOf(0x0a);
141
+ if (lastNewline >= 0) {
142
+ const completeLines = combined.subarray(0, lastNewline + 1);
143
+ this.pendingBytes = Buffer.from(combined.subarray(lastNewline + 1));
144
+ this.parser.processChunk(completeLines.toString('utf8'));
145
+ }
146
+ else {
147
+ this.pendingBytes = combined;
148
+ }
149
+ this.committedOffset = this.readOffset - this.pendingBytes.length;
132
150
  this.options.onBatchComplete?.({
133
151
  bytesRead,
134
152
  eventsRead: this.eventsInCurrentBatch,
135
- offset: this.offset,
153
+ offset: this.committedOffset,
136
154
  });
137
155
  }
138
156
  catch (error) {
@@ -148,10 +166,12 @@ class JsonlTailReader {
148
166
  }
149
167
  }
150
168
  getOffset() {
151
- return this.offset;
169
+ return this.committedOffset;
152
170
  }
153
171
  seekTo(offset) {
154
- this.offset = Math.max(0, offset);
172
+ this.readOffset = Math.max(0, offset);
173
+ this.committedOffset = this.readOffset;
174
+ this.pendingBytes = Buffer.alloc(0);
155
175
  this.parser.reset();
156
176
  }
157
177
  watchFile() {
@@ -274,9 +274,11 @@ class JsonlSessionWatcher {
274
274
  this.fsWatcher = fs.watch(this.sessionPath, { persistent: false }, () => {
275
275
  this.debouncedRead();
276
276
  });
277
- this.fsWatcher.on('error', () => {
278
- // File may have been deleted; stop gracefully
279
- this.stop();
277
+ this.fsWatcher.on('error', (error) => {
278
+ // Degrade to catch-up polling without killing the live session.
279
+ this.fsWatcher?.close();
280
+ this.fsWatcher = null;
281
+ this.callbacks.onError?.(error);
280
282
  });
281
283
  }
282
284
  catch {
@@ -82,8 +82,10 @@ class ProviderReaderSessionWatcher {
82
82
  this.fsWatcher = fs.watch(this.sessionPath, { persistent: false }, () => {
83
83
  this.debouncedRead();
84
84
  });
85
- this.fsWatcher.on('error', () => {
86
- this.stop();
85
+ this.fsWatcher.on('error', (error) => {
86
+ this.fsWatcher?.close();
87
+ this.fsWatcher = null;
88
+ this.callbacks.onError?.(error);
87
89
  });
88
90
  }
89
91
  catch {
@@ -7,6 +7,8 @@ export declare class SqliteSessionWatcher implements SessionWatcher {
7
7
  private _isActive;
8
8
  private lastMessageTime;
9
9
  private lastPartTime;
10
+ private seenMessageVersions;
11
+ private seenPartVersions;
10
12
  private fsWatcher;
11
13
  private walWatcher;
12
14
  private pollTimer;
@@ -164,6 +164,8 @@ class SqliteSessionWatcher {
164
164
  _isActive = false;
165
165
  lastMessageTime = 0;
166
166
  lastPartTime = 0;
167
+ seenMessageVersions = new Map();
168
+ seenPartVersions = new Map();
167
169
  fsWatcher = null;
168
170
  walWatcher = null;
169
171
  pollTimer = null;
@@ -185,6 +187,8 @@ class SqliteSessionWatcher {
185
187
  seekTo(position) {
186
188
  this.lastMessageTime = position;
187
189
  this.lastPartTime = position;
190
+ this.seenMessageVersions.clear();
191
+ this.seenPartVersions.clear();
188
192
  }
189
193
  /** Get the current cursor position (max timestamp seen). */
190
194
  getPosition() {
@@ -266,38 +270,47 @@ class SqliteSessionWatcher {
266
270
  }, DEBOUNCE_MS);
267
271
  }
268
272
  skipToEnd() {
269
- const messages = this.db.getMessagesForSession(this.sessionId);
270
- const parts = this.db.getPartsForSession(this.sessionId);
271
- if (messages.length > 0) {
272
- this.lastMessageTime = Math.max(...messages.map((m) => m.time_created));
273
+ this.lastMessageTime = this.db.getLatestMessageTimeUpdated(this.sessionId);
274
+ this.lastPartTime = this.db.getLatestPartTimeUpdated(this.sessionId);
275
+ if (this.lastMessageTime > 0) {
276
+ for (const message of this.db.getMessagesNewerThan(this.sessionId, this.lastMessageTime - 1)) {
277
+ this.seenMessageVersions.set(message.id, `${message.time_updated}:${message.data}`);
278
+ }
273
279
  }
274
- if (parts.length > 0) {
275
- this.lastPartTime = Math.max(...parts.map((p) => p.time_created));
280
+ if (this.lastPartTime > 0) {
281
+ for (const part of this.db.getPartsNewerThan(this.sessionId, this.lastPartTime - 1)) {
282
+ this.seenPartVersions.set(part.id, `${part.time_updated}:${part.data}`);
283
+ }
276
284
  }
277
285
  }
278
286
  pollNewData() {
279
287
  if (!this._isActive)
280
288
  return;
281
289
  try {
282
- const messages = this.db.getMessagesForSession(this.sessionId);
283
- const parts = this.db.getPartsForSession(this.sessionId);
290
+ // Query incrementally by update time. Subtracting one millisecond lets us
291
+ // see distinct rows committed at the cursor timestamp; version maps
292
+ // suppress rows whose id/data have already been emitted.
293
+ const messages = this.db.getMessagesNewerThan(this.sessionId, Math.max(-1, this.lastMessageTime - 1));
294
+ const parts = this.db.getPartsNewerThan(this.sessionId, Math.max(-1, this.lastPartTime - 1));
284
295
  // Emit new messages
285
- const newMessages = messages.filter((m) => m.time_created > this.lastMessageTime);
296
+ const newMessages = messages.filter((message) => this.seenMessageVersions.get(message.id) !== `${message.time_updated}:${message.data}`);
286
297
  for (const msg of newMessages) {
287
298
  const events = normalizeMessage(msg);
288
299
  for (const e of events)
289
300
  this.callbacks.onEvent(e);
290
- if (msg.time_created > this.lastMessageTime)
291
- this.lastMessageTime = msg.time_created;
301
+ this.seenMessageVersions.set(msg.id, `${msg.time_updated}:${msg.data}`);
302
+ if (msg.time_updated > this.lastMessageTime)
303
+ this.lastMessageTime = msg.time_updated;
292
304
  }
293
305
  // Emit new parts
294
- const newParts = parts.filter((p) => p.time_created > this.lastPartTime);
306
+ const newParts = parts.filter((part) => this.seenPartVersions.get(part.id) !== `${part.time_updated}:${part.data}`);
295
307
  for (const part of newParts) {
296
308
  const events = normalizePart(part);
297
309
  for (const e of events)
298
310
  this.callbacks.onEvent(e);
299
- if (part.time_created > this.lastPartTime)
300
- this.lastPartTime = part.time_created;
311
+ this.seenPartVersions.set(part.id, `${part.time_updated}:${part.data}`);
312
+ if (part.time_updated > this.lastPartTime)
313
+ this.lastPartTime = part.time_updated;
301
314
  }
302
315
  }
303
316
  catch (err) {
@@ -39,17 +39,53 @@ exports.updateJsonStoreAtomic = updateJsonStoreAtomic;
39
39
  const crypto = __importStar(require("crypto"));
40
40
  const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
+ const LOCK_STALE_MS = 10_000;
43
+ async function syncDirectory(directory) {
44
+ let handle;
45
+ try {
46
+ handle = await fs.promises.open(directory, 'r');
47
+ await handle.sync();
48
+ }
49
+ catch (error) {
50
+ const code = error.code;
51
+ if (!['EISDIR', 'EINVAL', 'ENOSYS', 'EPERM', 'EACCES'].includes(code ?? ''))
52
+ throw error;
53
+ }
54
+ finally {
55
+ await handle?.close().catch(() => undefined);
56
+ }
57
+ }
58
+ function syncDirectorySync(directory) {
59
+ let descriptor;
60
+ try {
61
+ descriptor = fs.openSync(directory, 'r');
62
+ fs.fsyncSync(descriptor);
63
+ }
64
+ catch (error) {
65
+ const code = error.code;
66
+ if (!['EISDIR', 'EINVAL', 'ENOSYS', 'EPERM', 'EACCES'].includes(code ?? ''))
67
+ throw error;
68
+ }
69
+ finally {
70
+ if (descriptor !== undefined)
71
+ fs.closeSync(descriptor);
72
+ }
73
+ }
42
74
  async function atomicWriteJson(filePath, value) {
43
75
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
44
76
  const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
77
+ let handle;
45
78
  try {
46
- await fs.promises.writeFile(tempPath, JSON.stringify(value, null, 2), {
47
- encoding: 'utf8',
48
- mode: 0o600,
49
- });
79
+ handle = await fs.promises.open(tempPath, 'wx', 0o600);
80
+ await handle.writeFile(JSON.stringify(value, null, 2), 'utf8');
81
+ await handle.sync();
82
+ await handle.close();
83
+ handle = undefined;
50
84
  await fs.promises.rename(tempPath, filePath);
85
+ await syncDirectory(path.dirname(filePath));
51
86
  }
52
87
  catch (error) {
88
+ await handle?.close().catch(() => undefined);
53
89
  await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
54
90
  throw error;
55
91
  }
@@ -57,11 +93,19 @@ async function atomicWriteJson(filePath, value) {
57
93
  function atomicWriteJsonSync(filePath, value) {
58
94
  fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
59
95
  const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
96
+ let descriptor;
60
97
  try {
61
- fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });
98
+ descriptor = fs.openSync(tempPath, 'wx', 0o600);
99
+ fs.writeFileSync(descriptor, JSON.stringify(value, null, 2), { encoding: 'utf8' });
100
+ fs.fsyncSync(descriptor);
101
+ fs.closeSync(descriptor);
102
+ descriptor = undefined;
62
103
  fs.renameSync(tempPath, filePath);
104
+ syncDirectorySync(path.dirname(filePath));
63
105
  }
64
106
  catch (error) {
107
+ if (descriptor !== undefined)
108
+ fs.closeSync(descriptor);
65
109
  fs.rmSync(tempPath, { force: true });
66
110
  throw error;
67
111
  }
@@ -73,34 +117,65 @@ async function updateJsonStoreAtomic(filePath, createEmpty, update) {
73
117
  const lock = await acquireLock(lockPath);
74
118
  try {
75
119
  let latest = createEmpty();
120
+ let raw;
76
121
  try {
77
- latest = JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
122
+ raw = await fs.promises.readFile(filePath, 'utf8');
123
+ }
124
+ catch (error) {
125
+ if (error.code !== 'ENOENT')
126
+ throw error;
78
127
  }
79
- catch {
80
- // Missing/malformed stores start from the schema-correct empty value.
128
+ if (raw !== undefined) {
129
+ try {
130
+ latest = JSON.parse(raw);
131
+ }
132
+ catch {
133
+ const corruptPath = `${filePath}.corrupt-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
134
+ await fs.promises.rename(filePath, corruptPath);
135
+ }
81
136
  }
82
137
  const next = await update(latest);
83
138
  await atomicWriteJson(filePath, next);
84
139
  return next;
85
140
  }
86
141
  finally {
87
- await lock.close().catch(() => undefined);
88
- await fs.promises.rm(lockPath, { force: true }).catch(() => undefined);
142
+ await releaseLock(lockPath, lock);
89
143
  }
90
144
  }
91
145
  async function acquireLock(lockPath) {
92
146
  const deadline = Date.now() + 3000;
93
147
  while (true) {
94
148
  try {
95
- return await fs.promises.open(lockPath, 'wx', 0o600);
149
+ const handle = await fs.promises.open(lockPath, 'wx', 0o600);
150
+ const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
151
+ await handle.writeFile(token, 'utf8');
152
+ await handle.sync();
153
+ const heartbeat = setInterval(() => {
154
+ fs.promises.utimes(lockPath, new Date(), new Date()).catch(() => undefined);
155
+ }, Math.floor(LOCK_STALE_MS / 3));
156
+ heartbeat.unref();
157
+ return { handle, token, heartbeat };
96
158
  }
97
159
  catch (error) {
98
160
  if (error.code !== 'EEXIST')
99
161
  throw error;
100
162
  try {
101
163
  const stat = await fs.promises.stat(lockPath);
102
- if (Date.now() - stat.mtimeMs > 10_000)
103
- await fs.promises.rm(lockPath, { force: true });
164
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
165
+ const owner = await fs.promises.readFile(lockPath, 'utf8').catch(() => '');
166
+ const ownerPid = Number.parseInt(owner.split(':', 1)[0], 10);
167
+ let ownerAlive = Number.isInteger(ownerPid) && ownerPid > 0;
168
+ if (ownerAlive) {
169
+ try {
170
+ process.kill(ownerPid, 0);
171
+ }
172
+ catch (ownerError) {
173
+ ownerAlive = ownerError.code === 'EPERM';
174
+ }
175
+ }
176
+ if (!ownerAlive)
177
+ await fs.promises.rm(lockPath, { force: true });
178
+ }
104
179
  }
105
180
  catch {
106
181
  // The other writer released between checks.
@@ -111,3 +186,15 @@ async function acquireLock(lockPath) {
111
186
  }
112
187
  }
113
188
  }
189
+ async function releaseLock(lockPath, lock) {
190
+ clearInterval(lock.heartbeat);
191
+ await lock.handle.close().catch(() => undefined);
192
+ try {
193
+ const currentToken = await fs.promises.readFile(lockPath, 'utf8');
194
+ if (currentToken === lock.token)
195
+ await fs.promises.rm(lockPath, { force: true });
196
+ }
197
+ catch {
198
+ // The lock was already released or replaced; never delete another owner's lock.
199
+ }
200
+ }
@@ -169,6 +169,12 @@ class ZaiQuotaWatcher {
169
169
  }
170
170
  recomputeAndEmit() {
171
171
  const nowMs = this.now();
172
+ if (this.lastError?.resetsAt) {
173
+ const resetsAt = Date.parse(this.lastError.resetsAt);
174
+ if (Number.isFinite(resetsAt) && resetsAt <= nowMs) {
175
+ this.lastError = null;
176
+ }
177
+ }
172
178
  const accumulated = (0, zaiQuota_1.accumulateZaiUsage)(this.turns, nowMs);
173
179
  const tier = this.resolveTier(this.tier, accumulated);
174
180
  const state = accumulated.fiveHourTurns > 0 || accumulated.weeklyTurns > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",