sidekick-shared 0.24.3 → 0.24.5

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 (52) hide show
  1. package/CHANGELOG.md +436 -0
  2. package/LICENSE +21 -0
  3. package/README.md +172 -60
  4. package/dist/accountManager.d.ts +13 -0
  5. package/dist/accountManager.js +60 -32
  6. package/dist/accountRegistry.js +59 -41
  7. package/dist/accounts.js +40 -19
  8. package/dist/autoSwitch.d.ts +2 -1
  9. package/dist/autoSwitch.js +32 -7
  10. package/dist/browser.d.ts +2 -0
  11. package/dist/browser.js +3 -1
  12. package/dist/codexProfiles.d.ts +14 -0
  13. package/dist/codexProfiles.js +268 -116
  14. package/dist/context/sessionContext.js +2 -4
  15. package/dist/ensureDefaultAccounts.js +4 -4
  16. package/dist/index.d.ts +14 -7
  17. package/dist/index.js +33 -7
  18. package/dist/modelInfo.js +11 -2
  19. package/dist/node.d.ts +6 -0
  20. package/dist/node.js +8 -1
  21. package/dist/observedContextWindows.d.ts +20 -4
  22. package/dist/observedContextWindows.js +47 -7
  23. package/dist/parsers/codexHistory.d.ts +30 -0
  24. package/dist/parsers/codexHistory.js +118 -0
  25. package/dist/parsers/mcpToolName.d.ts +26 -0
  26. package/dist/parsers/mcpToolName.js +31 -0
  27. package/dist/parsers/sessionPathResolver.d.ts +11 -4
  28. package/dist/parsers/sessionPathResolver.js +11 -4
  29. package/dist/paths.d.ts +43 -4
  30. package/dist/paths.js +66 -6
  31. package/dist/peakHours.d.ts +16 -2
  32. package/dist/peakHours.js +18 -3
  33. package/dist/pricingCatalog.d.ts +7 -3
  34. package/dist/pricingCatalog.js +3 -2
  35. package/dist/providers/claudeCode.d.ts +2 -1
  36. package/dist/providers/claudeCode.js +18 -0
  37. package/dist/providers/codex.d.ts +22 -1
  38. package/dist/providers/codex.js +58 -0
  39. package/dist/providers/types.d.ts +5 -0
  40. package/dist/quotaHistory.d.ts +1 -1
  41. package/dist/quotaHistory.js +2 -2
  42. package/dist/quotaSnapshots.js +8 -77
  43. package/dist/sessionPreviews.d.ts +60 -0
  44. package/dist/sessionPreviews.js +234 -0
  45. package/dist/sessionTranscripts.js +10 -4
  46. package/dist/writers/atomic.d.ts +20 -0
  47. package/dist/writers/atomic.js +190 -19
  48. package/dist/zaiQuota.d.ts +87 -0
  49. package/dist/zaiQuota.js +49 -0
  50. package/dist/zaiQuotaWatcher.d.ts +7 -0
  51. package/dist/zaiQuotaWatcher.js +3 -0
  52. package/package.json +60 -2
@@ -35,40 +35,17 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.writeQuotaSnapshot = writeQuotaSnapshot;
37
37
  exports.readQuotaSnapshot = readQuotaSnapshot;
38
- const crypto = __importStar(require("crypto"));
39
38
  const fs = __importStar(require("fs"));
40
39
  const path = __importStar(require("path"));
41
40
  const paths_1 = require("./paths");
41
+ const atomic_1 = require("./writers/atomic");
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));
47
43
  function getQuotaSnapshotPath() {
48
44
  return path.join((0, paths_1.getConfigDir)(), 'quota-snapshots.json');
49
45
  }
50
46
  function ensureConfigDir() {
51
47
  fs.mkdirSync((0, paths_1.getConfigDir)(), { recursive: true, mode: 0o700 });
52
48
  }
53
- function atomicWriteJson(filePath, data, mode = 0o600) {
54
- const tmp = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomBytes(8).toString('hex')}.tmp`;
55
- const json = JSON.stringify(data, null, 2);
56
- // Throws if `data` was undefined or a top-level non-serializable value — prevents writing the literal string "undefined" to disk.
57
- JSON.parse(json);
58
- try {
59
- fs.writeFileSync(tmp, json, { encoding: 'utf8', mode });
60
- fs.renameSync(tmp, filePath);
61
- }
62
- catch (error) {
63
- try {
64
- fs.rmSync(tmp, { force: true });
65
- }
66
- catch {
67
- // Best effort cleanup only.
68
- }
69
- throw error;
70
- }
71
- }
72
49
  function readStore() {
73
50
  try {
74
51
  const parsed = JSON.parse(fs.readFileSync(getQuotaSnapshotPath(), 'utf8'));
@@ -86,62 +63,16 @@ function readStore() {
86
63
  }
87
64
  function writeStore(store) {
88
65
  ensureConfigDir();
89
- atomicWriteJson(getQuotaSnapshotPath(), store);
66
+ (0, atomic_1.atomicWriteJsonSync)(getQuotaSnapshotPath(), store);
90
67
  }
68
+ /**
69
+ * Serializes snapshot read-modify-write cycles across processes on the shared
70
+ * store lock. The lock file path is unchanged from the previous local
71
+ * implementation, so processes running older versions still exclude correctly.
72
+ */
91
73
  function withSnapshotStoreLock(operation) {
92
74
  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
- }
75
+ return (0, atomic_1.withFileLockSync)(`${getQuotaSnapshotPath()}.lock`, operation);
145
76
  }
146
77
  function snapshotTimeMs(quota) {
147
78
  const capturedAt = quota.capturedAt ? Date.parse(quota.capturedAt) : NaN;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cheap, bounded previews of provider-native session files (Node-only).
3
+ *
4
+ * `listRecentSessions` answers "what happened in this workspace" but labels
5
+ * every discovered session before applying its limit, so its cost scales with
6
+ * total session count. This module inverts the order of operations: enumerate
7
+ * with stats only, sort by mtime, apply `since`/`limit`, and only then do
8
+ * bounded content reads for the survivors. That keeps a "recent sessions"
9
+ * surface cheap even against a machine with thousands of accumulated sessions.
10
+ *
11
+ * Prompt extraction delegates to each provider's `extractSessionLabel` (Claude
12
+ * Code reads only the first 8 KiB; Codex prefers the sqlite thread title, else
13
+ * a bounded first-user-message scan that skips injected context). The first
14
+ * timestamp and workspace path come from one generic prefix parse in this
15
+ * module. A session whose opening events are larger than the prefix budget
16
+ * yields `null` fields rather than an error.
17
+ *
18
+ * @module sessionPreviews
19
+ */
20
+ import type { ProviderId, SessionProviderBase } from './providers/types';
21
+ export interface SessionPreview {
22
+ provider: ProviderId;
23
+ sessionId: string;
24
+ filePath: string;
25
+ /** ISO mtime of the session file. */
26
+ modifiedAt: string;
27
+ sizeBytes: number;
28
+ /**
29
+ * First real user prompt, as truncated by the provider (~60 chars). Codex
30
+ * may substitute the sqlite thread title when the database is reachable.
31
+ */
32
+ firstUserPrompt: string | null;
33
+ /** ISO timestamp of the first event found in the bounded prefix, if any. */
34
+ firstTimestamp: string | null;
35
+ /** Workspace/cwd recorded in the prefix (Claude `cwd`, Codex `session_meta.payload.cwd`), if any. */
36
+ workspacePath: string | null;
37
+ }
38
+ export interface ReadSessionPreviewOptions {
39
+ /** Byte budget for the generic prefix scan (default 16 KiB). */
40
+ maxPrefixBytes?: number;
41
+ }
42
+ export interface ListSessionPreviewsOptions extends ReadSessionPreviewOptions {
43
+ /** Maximum previews returned (default 50). Content reads happen only for the returned slice. */
44
+ limit?: number;
45
+ /** Restrict to one workspace (via `provider.findAllSessions`); omit for all workspaces. */
46
+ workspacePath?: string;
47
+ /** Skip files whose mtime is not strictly newer than this — cheap incremental refresh. */
48
+ since?: Date | string;
49
+ }
50
+ /**
51
+ * Reads a bounded preview of one session file. Returns `null` when the file is
52
+ * missing or unreadable; malformed content degrades to `null` fields instead
53
+ * of throwing.
54
+ */
55
+ export declare function readSessionPreview(provider: SessionProviderBase, sessionPath: string, options?: ReadSessionPreviewOptions): SessionPreview | null;
56
+ /**
57
+ * Lists previews across providers, newest first. Enumeration is stat-only;
58
+ * content is read only for the post-`limit` survivors.
59
+ */
60
+ export declare function listSessionPreviews(providers: SessionProviderBase[], options?: ListSessionPreviewsOptions): SessionPreview[];
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ /**
3
+ * Cheap, bounded previews of provider-native session files (Node-only).
4
+ *
5
+ * `listRecentSessions` answers "what happened in this workspace" but labels
6
+ * every discovered session before applying its limit, so its cost scales with
7
+ * total session count. This module inverts the order of operations: enumerate
8
+ * with stats only, sort by mtime, apply `since`/`limit`, and only then do
9
+ * bounded content reads for the survivors. That keeps a "recent sessions"
10
+ * surface cheap even against a machine with thousands of accumulated sessions.
11
+ *
12
+ * Prompt extraction delegates to each provider's `extractSessionLabel` (Claude
13
+ * Code reads only the first 8 KiB; Codex prefers the sqlite thread title, else
14
+ * a bounded first-user-message scan that skips injected context). The first
15
+ * timestamp and workspace path come from one generic prefix parse in this
16
+ * module. A session whose opening events are larger than the prefix budget
17
+ * yields `null` fields rather than an error.
18
+ *
19
+ * @module sessionPreviews
20
+ */
21
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ var desc = Object.getOwnPropertyDescriptor(m, k);
24
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
25
+ desc = { enumerable: true, get: function() { return m[k]; } };
26
+ }
27
+ Object.defineProperty(o, k2, desc);
28
+ }) : (function(o, m, k, k2) {
29
+ if (k2 === undefined) k2 = k;
30
+ o[k2] = m[k];
31
+ }));
32
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
33
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
34
+ }) : function(o, v) {
35
+ o["default"] = v;
36
+ });
37
+ var __importStar = (this && this.__importStar) || (function () {
38
+ var ownKeys = function(o) {
39
+ ownKeys = Object.getOwnPropertyNames || function (o) {
40
+ var ar = [];
41
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
42
+ return ar;
43
+ };
44
+ return ownKeys(o);
45
+ };
46
+ return function (mod) {
47
+ if (mod && mod.__esModule) return mod;
48
+ var result = {};
49
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
50
+ __setModuleDefault(result, mod);
51
+ return result;
52
+ };
53
+ })();
54
+ Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.readSessionPreview = readSessionPreview;
56
+ exports.listSessionPreviews = listSessionPreviews;
57
+ const fs = __importStar(require("fs"));
58
+ const DEFAULT_PREFIX_BYTES = 16 * 1024;
59
+ const DEFAULT_LIMIT = 50;
60
+ /**
61
+ * Reads a bounded preview of one session file. Returns `null` when the file is
62
+ * missing or unreadable; malformed content degrades to `null` fields instead
63
+ * of throwing.
64
+ */
65
+ function readSessionPreview(provider, sessionPath, options = {}) {
66
+ let stats;
67
+ let modifiedAt;
68
+ try {
69
+ stats = fs.statSync(sessionPath);
70
+ // Inside the try: an out-of-range mtime RangeErrors on serialization,
71
+ // and this function's contract is to degrade, not throw.
72
+ modifiedAt = stats.mtime.toISOString();
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ let firstUserPrompt = null;
78
+ try {
79
+ firstUserPrompt = provider.extractSessionLabel(sessionPath);
80
+ }
81
+ catch {
82
+ // Label extraction is best-effort.
83
+ }
84
+ let sessionId = '';
85
+ try {
86
+ sessionId = provider.getSessionId(sessionPath);
87
+ }
88
+ catch {
89
+ // A provider that cannot identify the file still yields a usable preview.
90
+ }
91
+ const prefix = scanPrefix(sessionPath, options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES);
92
+ return {
93
+ provider: provider.id,
94
+ sessionId,
95
+ filePath: sessionPath,
96
+ modifiedAt,
97
+ sizeBytes: stats.size,
98
+ firstUserPrompt,
99
+ firstTimestamp: prefix.firstTimestamp,
100
+ workspacePath: prefix.workspacePath,
101
+ };
102
+ }
103
+ /**
104
+ * Lists previews across providers, newest first. Enumeration is stat-only;
105
+ * content is read only for the post-`limit` survivors.
106
+ */
107
+ function listSessionPreviews(providers, options = {}) {
108
+ const limit = Math.max(0, options.limit ?? DEFAULT_LIMIT);
109
+ const sinceMs = resolveSinceMs(options.since);
110
+ const candidates = [];
111
+ for (const provider of providers) {
112
+ for (const file of enumerateSessionFiles(provider, options.workspacePath)) {
113
+ const mtimeMs = file.mtime.getTime();
114
+ if (sinceMs !== null && mtimeMs <= sinceMs)
115
+ continue;
116
+ candidates.push({ provider, path: file.path, mtimeMs });
117
+ }
118
+ }
119
+ candidates.sort((left, right) => right.mtimeMs - left.mtimeMs);
120
+ const previews = [];
121
+ for (const candidate of candidates.slice(0, limit)) {
122
+ const preview = readSessionPreview(candidate.provider, candidate.path, options);
123
+ if (preview)
124
+ previews.push(preview);
125
+ }
126
+ return previews;
127
+ }
128
+ function resolveSinceMs(since) {
129
+ if (since === undefined)
130
+ return null;
131
+ const ms = since instanceof Date ? since.getTime() : Date.parse(since);
132
+ return Number.isFinite(ms) ? ms : null;
133
+ }
134
+ function enumerateSessionFiles(provider, workspacePath) {
135
+ try {
136
+ if (workspacePath) {
137
+ return provider
138
+ .findAllSessions(workspacePath)
139
+ .map(statSessionFile)
140
+ .filter((info) => info !== null);
141
+ }
142
+ if (provider.listAllSessionFiles)
143
+ return provider.listAllSessionFiles();
144
+ // Best-effort global fallback for providers without listAllSessionFiles:
145
+ // correct for project-folder-shaped providers, empty otherwise.
146
+ const results = [];
147
+ const seen = new Set();
148
+ for (const folder of provider.getAllProjectFolders()) {
149
+ for (const sessionPath of provider.findSessionsInDirectory(folder.dir)) {
150
+ if (seen.has(sessionPath))
151
+ continue;
152
+ seen.add(sessionPath);
153
+ const info = statSessionFile(sessionPath);
154
+ if (info)
155
+ results.push(info);
156
+ }
157
+ }
158
+ return results;
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ }
164
+ function statSessionFile(sessionPath) {
165
+ try {
166
+ return { path: sessionPath, mtime: fs.statSync(sessionPath).mtime };
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ /**
173
+ * Parses complete JSONL lines out of the first `maxPrefixBytes` of a file,
174
+ * taking the first top-level `timestamp` string and the first workspace path
175
+ * (top-level `cwd`, or `payload.cwd` on a Codex `session_meta` row). Partial
176
+ * trailing lines and malformed rows are skipped; never throws.
177
+ */
178
+ function scanPrefix(sessionPath, maxPrefixBytes) {
179
+ const result = { firstTimestamp: null, workspacePath: null };
180
+ let content;
181
+ let sawWholeFile;
182
+ try {
183
+ const descriptor = fs.openSync(sessionPath, 'r');
184
+ try {
185
+ const size = fs.fstatSync(descriptor).size;
186
+ const readBytes = Math.min(size, Math.max(1, maxPrefixBytes));
187
+ const buffer = Buffer.alloc(readBytes);
188
+ fs.readSync(descriptor, buffer, 0, readBytes, 0);
189
+ content = buffer.toString('utf8');
190
+ sawWholeFile = readBytes >= size;
191
+ }
192
+ finally {
193
+ fs.closeSync(descriptor);
194
+ }
195
+ }
196
+ catch {
197
+ return result;
198
+ }
199
+ const lines = content.split('\n');
200
+ // The final chunk after the last newline is a partial line unless the whole
201
+ // file fit in the prefix.
202
+ const completeLines = sawWholeFile ? lines : lines.slice(0, -1);
203
+ for (const line of completeLines) {
204
+ const trimmed = line.trim();
205
+ if (!trimmed)
206
+ continue;
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(trimmed);
210
+ }
211
+ catch {
212
+ continue;
213
+ }
214
+ if (parsed === null || typeof parsed !== 'object')
215
+ continue;
216
+ if (result.firstTimestamp === null && typeof parsed.timestamp === 'string') {
217
+ result.firstTimestamp = parsed.timestamp;
218
+ }
219
+ if (result.workspacePath === null) {
220
+ if (typeof parsed.cwd === 'string' && parsed.cwd) {
221
+ result.workspacePath = parsed.cwd;
222
+ }
223
+ else if (parsed.type === 'session_meta') {
224
+ const payload = parsed.payload;
225
+ if (payload && typeof payload.cwd === 'string' && payload.cwd) {
226
+ result.workspacePath = payload.cwd;
227
+ }
228
+ }
229
+ }
230
+ if (result.firstTimestamp !== null && result.workspacePath !== null)
231
+ break;
232
+ }
233
+ return result;
234
+ }
@@ -7,17 +7,23 @@ const transcript_1 = require("./transcript");
7
7
  /** List recent sessions using the provider's existing discovery and metadata APIs. */
8
8
  function listRecentSessions(provider, workspacePath, options = {}) {
9
9
  const limit = Math.max(0, options.limit ?? 20);
10
+ // Sort on mtime alone first so labels — the only per-file content read —
11
+ // are extracted just for the returned slice, not every discovered session.
10
12
  return provider
11
13
  .findAllSessions(workspacePath)
12
14
  .map((sessionPath) => ({
13
- provider: provider.id,
14
- sessionId: provider.getSessionId(sessionPath),
15
15
  sessionPath,
16
- label: provider.extractSessionLabel(sessionPath),
17
16
  modifiedAt: sessionModifiedAt(provider, sessionPath).toISOString(),
18
17
  }))
19
18
  .sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt))
20
- .slice(0, limit);
19
+ .slice(0, limit)
20
+ .map(({ sessionPath, modifiedAt }) => ({
21
+ provider: provider.id,
22
+ sessionId: provider.getSessionId(sessionPath),
23
+ sessionPath,
24
+ label: provider.extractSessionLabel(sessionPath),
25
+ modifiedAt,
26
+ }));
21
27
  }
22
28
  function sessionModifiedAt(provider, sessionPath) {
23
29
  const metadata = provider.getSessionMetadata?.(sessionPath);
@@ -1,4 +1,24 @@
1
1
  export declare function atomicWriteJson(filePath: string, value: unknown): Promise<void>;
2
2
  export declare function atomicWriteJsonSync(filePath: string, value: unknown): void;
3
+ /** Raw-string sibling of {@link atomicWriteJsonSync} for non-JSON payloads. */
4
+ export declare function atomicWriteFileSync(filePath: string, content: string, mode?: number): void;
3
5
  /** Serializes read-modify-write cycles across Sidekick processes. */
4
6
  export declare function updateJsonStoreAtomic<T>(filePath: string, createEmpty: () => T, update: (latest: T) => T | Promise<T>): Promise<T>;
7
+ /**
8
+ * Synchronous counterpart of {@link acquireLock}, sharing its lock-file
9
+ * protocol so sync and async waiters exclude each other across processes.
10
+ *
11
+ * Two timeout rules apply instead of one: the same progress-based budget as the
12
+ * async path (a token that never changes trips it in LOCK_NO_PROGRESS_MS), plus
13
+ * an absolute {@link SYNC_LOCK_MAX_WAIT_MS} ceiling, because a sync waiter
14
+ * blocks its event loop while queued. Sync holders take no heartbeat — their
15
+ * critical sections must stay well under LOCK_STALE_MS (in-repo users are
16
+ * local-filesystem read-modify-writes, plus keychain reads behind the
17
+ * auth-swap locks that can take a few seconds).
18
+ *
19
+ * The caller must ensure `dirname(lockPath)` exists — a missing parent
20
+ * surfaces as a raw ENOENT, not a timeout.
21
+ */
22
+ export declare function withFileLockSync<T>(lockPath: string, operation: () => T): T;
23
+ /** Synchronous mirror of {@link updateJsonStoreAtomic}. */
24
+ export declare function updateJsonStoreAtomicSync<T>(filePath: string, createEmpty: () => T, update: (latest: T) => T): T;