sidekick-shared 0.24.4 → 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.
- package/CHANGELOG.md +24 -0
- package/README.md +86 -45
- package/dist/accountManager.d.ts +13 -0
- package/dist/accountManager.js +60 -32
- package/dist/accountRegistry.js +59 -41
- package/dist/accounts.js +40 -19
- package/dist/autoSwitch.d.ts +2 -1
- package/dist/autoSwitch.js +32 -7
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +3 -1
- package/dist/codexProfiles.d.ts +14 -0
- package/dist/codexProfiles.js +268 -116
- package/dist/context/sessionContext.js +2 -4
- package/dist/ensureDefaultAccounts.js +4 -4
- package/dist/index.d.ts +11 -4
- package/dist/index.js +28 -7
- package/dist/node.d.ts +6 -0
- package/dist/node.js +8 -1
- package/dist/observedContextWindows.d.ts +13 -0
- package/dist/observedContextWindows.js +13 -0
- package/dist/parsers/codexHistory.d.ts +30 -0
- package/dist/parsers/codexHistory.js +118 -0
- package/dist/parsers/mcpToolName.d.ts +26 -0
- package/dist/parsers/mcpToolName.js +31 -0
- package/dist/parsers/sessionPathResolver.d.ts +11 -4
- package/dist/parsers/sessionPathResolver.js +11 -4
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +7 -3
- package/dist/providers/claudeCode.d.ts +2 -1
- package/dist/providers/claudeCode.js +18 -0
- package/dist/providers/codex.d.ts +22 -1
- package/dist/providers/codex.js +58 -0
- package/dist/providers/types.d.ts +5 -0
- package/dist/quotaHistory.d.ts +1 -1
- package/dist/quotaHistory.js +2 -2
- package/dist/quotaSnapshots.js +8 -77
- package/dist/sessionPreviews.d.ts +60 -0
- package/dist/sessionPreviews.js +234 -0
- package/dist/sessionTranscripts.js +10 -4
- package/dist/writers/atomic.d.ts +20 -0
- package/dist/writers/atomic.js +190 -19
- package/package.json +1 -1
|
@@ -9,9 +9,29 @@
|
|
|
9
9
|
* Ported from sidekick-vscode/src/services/providers/CodexSessionProvider.ts
|
|
10
10
|
*/
|
|
11
11
|
import type { ReadSessionContextSnapshotOptions, SessionContextSnapshot } from '../context/sessionContext';
|
|
12
|
-
import type { SessionProviderBase, SessionReader, ProjectFolderInfo, SearchHit, SessionFileStats, ProviderId } from './types';
|
|
12
|
+
import type { SessionProviderBase, SessionReader, ProjectFolderInfo, SearchHit, SessionFileInfo, SessionFileStats, ProviderId } from './types';
|
|
13
13
|
import type { SubagentStats, TokenUsage } from '../types/sessionEvent';
|
|
14
14
|
import type { CodexRateLimits } from '../types/codex';
|
|
15
|
+
export interface FindCodexRolloutFileOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Codex home whose `sessions/` subtree to search. Defaults to every
|
|
18
|
+
* monitored home: `$CODEX_HOME` if set, else `~/.codex`, plus managed
|
|
19
|
+
* profile homes that recorded sessions.
|
|
20
|
+
*/
|
|
21
|
+
codexHome?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Locates the rollout file for a Codex session id by walking
|
|
25
|
+
* `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`.
|
|
26
|
+
*
|
|
27
|
+
* Filename-only match, case-insensitive on the id — no file contents are read
|
|
28
|
+
* and files are only stat'ed once their name matches, so the walk stays cheap
|
|
29
|
+
* even over a large history. When the same id exists in more than one home,
|
|
30
|
+
* the most recently modified copy wins. Returns `null` for an unknown, empty,
|
|
31
|
+
* or whitespace id. Consumers who want the database's answer instead can query
|
|
32
|
+
* the already-public `CodexDatabase` (requires the external `sqlite3` binary).
|
|
33
|
+
*/
|
|
34
|
+
export declare function findCodexRolloutFile(sessionId: string, options?: FindCodexRolloutFileOptions): string | null;
|
|
15
35
|
/**
|
|
16
36
|
* Session provider for Codex CLI.
|
|
17
37
|
*
|
|
@@ -35,6 +55,7 @@ export declare class CodexProvider implements SessionProviderBase {
|
|
|
35
55
|
/** Backward-compatible alias for findAllSessions. */
|
|
36
56
|
findSessionFiles(workspacePath: string): string[];
|
|
37
57
|
findSessionsInDirectory(dir: string): string[];
|
|
58
|
+
listAllSessionFiles(): SessionFileInfo[];
|
|
38
59
|
getAllProjectFolders(workspacePath?: string): ProjectFolderInfo[];
|
|
39
60
|
isSessionFile(filename: string): boolean;
|
|
40
61
|
getSessionId(sessionPath: string): string;
|
package/dist/providers/codex.js
CHANGED
|
@@ -44,6 +44,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
44
44
|
})();
|
|
45
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
46
46
|
exports.CodexProvider = void 0;
|
|
47
|
+
exports.findCodexRolloutFile = findCodexRolloutFile;
|
|
47
48
|
const fs = __importStar(require("fs"));
|
|
48
49
|
const path = __importStar(require("path"));
|
|
49
50
|
const string_decoder_1 = require("string_decoder");
|
|
@@ -164,6 +165,60 @@ function findRolloutFilesInConfiguredHomes() {
|
|
|
164
165
|
}
|
|
165
166
|
return results;
|
|
166
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Locates the rollout file for a Codex session id by walking
|
|
170
|
+
* `<home>/sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`.
|
|
171
|
+
*
|
|
172
|
+
* Filename-only match, case-insensitive on the id — no file contents are read
|
|
173
|
+
* and files are only stat'ed once their name matches, so the walk stays cheap
|
|
174
|
+
* even over a large history. When the same id exists in more than one home,
|
|
175
|
+
* the most recently modified copy wins. Returns `null` for an unknown, empty,
|
|
176
|
+
* or whitespace id. Consumers who want the database's answer instead can query
|
|
177
|
+
* the already-public `CodexDatabase` (requires the external `sqlite3` binary).
|
|
178
|
+
*/
|
|
179
|
+
function findCodexRolloutFile(sessionId, options = {}) {
|
|
180
|
+
const normalizedId = sessionId.trim().toLowerCase();
|
|
181
|
+
if (!normalizedId)
|
|
182
|
+
return null;
|
|
183
|
+
const sessionsDirs = options.codexHome
|
|
184
|
+
? [path.join(options.codexHome, 'sessions')]
|
|
185
|
+
: getSessionsDirs();
|
|
186
|
+
let best = null;
|
|
187
|
+
for (const dir of sessionsDirs) {
|
|
188
|
+
for (const candidate of findRolloutFilesById(dir, normalizedId)) {
|
|
189
|
+
if (!best || candidate.mtimeMs > best.mtimeMs)
|
|
190
|
+
best = candidate;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return best?.path ?? null;
|
|
194
|
+
}
|
|
195
|
+
function findRolloutFilesById(dir, normalizedId) {
|
|
196
|
+
const results = [];
|
|
197
|
+
try {
|
|
198
|
+
if (!fs.existsSync(dir))
|
|
199
|
+
return results;
|
|
200
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
201
|
+
const fullPath = path.join(dir, entry.name);
|
|
202
|
+
if (entry.isDirectory()) {
|
|
203
|
+
results.push(...findRolloutFilesById(fullPath, normalizedId));
|
|
204
|
+
}
|
|
205
|
+
else if (entry.isFile() &&
|
|
206
|
+
isRolloutFile(entry.name) &&
|
|
207
|
+
extractSessionId(entry.name).toLowerCase() === normalizedId) {
|
|
208
|
+
try {
|
|
209
|
+
results.push({ path: fullPath, mtimeMs: fs.statSync(fullPath).mtimeMs });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Skip files that vanish between readdir and stat.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Skip inaccessible directories.
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
167
222
|
/** Pick the best sessions directory for UI/search entrypoints. */
|
|
168
223
|
function getSessionsDir() {
|
|
169
224
|
const dirs = getSessionsDirs();
|
|
@@ -711,6 +766,9 @@ class CodexProvider {
|
|
|
711
766
|
files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
712
767
|
return files.map((f) => f.path);
|
|
713
768
|
}
|
|
769
|
+
listAllSessionFiles() {
|
|
770
|
+
return findRolloutFilesInConfiguredHomes();
|
|
771
|
+
}
|
|
714
772
|
getAllProjectFolders(workspacePath) {
|
|
715
773
|
const folders = [];
|
|
716
774
|
const seenCwds = new Map();
|
|
@@ -143,6 +143,11 @@ export interface SessionProviderBase {
|
|
|
143
143
|
readSessionContextSnapshot?(sessionPath: string, options?: ReadSessionContextSnapshotOptions): SessionContextSnapshot;
|
|
144
144
|
/** Reports provider runtime readiness for DB-backed providers. */
|
|
145
145
|
getRuntimeStatus?(): ProviderRuntimeStatus;
|
|
146
|
+
/**
|
|
147
|
+
* Lists every session file this provider knows about, across all workspaces.
|
|
148
|
+
* Stat-only — implementations must not read file contents.
|
|
149
|
+
*/
|
|
150
|
+
listAllSessionFiles?(): SessionFileInfo[];
|
|
146
151
|
/** Tests whether a provider can monitor a directory path, including synthetic DB-backed paths. */
|
|
147
152
|
canMonitorDirectory?(dir: string): boolean;
|
|
148
153
|
/** Release resources. */
|
package/dist/quotaHistory.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Sibling to `quotaSnapshots.ts`. Where snapshots persist a single most-recent sample per
|
|
5
5
|
* (provider, account), this module accumulates time-series samples so consumers (the VS Code
|
|
6
|
-
* dashboard, the `sidekick quota history` CLI,
|
|
6
|
+
* dashboard, the `sidekick quota history` CLI, external consumers) can render heatmaps and
|
|
7
7
|
* trend visualisations over a 13-week window.
|
|
8
8
|
*/
|
|
9
9
|
export type QuotaHistoryRuntimeProvider = 'claude' | 'codex' | 'zai';
|
package/dist/quotaHistory.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Sibling to `quotaSnapshots.ts`. Where snapshots persist a single most-recent sample per
|
|
6
6
|
* (provider, account), this module accumulates time-series samples so consumers (the VS Code
|
|
7
|
-
* dashboard, the `sidekick quota history` CLI,
|
|
7
|
+
* dashboard, the `sidekick quota history` CLI, external consumers) can render heatmaps and
|
|
8
8
|
* trend visualisations over a 13-week window.
|
|
9
9
|
*/
|
|
10
10
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
@@ -258,7 +258,7 @@ async function runAppend(sample, filePath, options) {
|
|
|
258
258
|
// ignore
|
|
259
259
|
}
|
|
260
260
|
// Backwards-compat: keep the latest-snapshot store hot so existing callers (DashboardViewProvider,
|
|
261
|
-
// codex session provider,
|
|
261
|
+
// codex session provider, external consumers) don't have to query history for "latest".
|
|
262
262
|
try {
|
|
263
263
|
const providerId = runtimeProviderToSnapshotProvider(sample.runtimeProvider);
|
|
264
264
|
(0, quotaSnapshots_1.writeQuotaSnapshot)(providerId, sample.providerId, sampleToQuotaState(sample));
|
package/dist/quotaSnapshots.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/writers/atomic.d.ts
CHANGED
|
@@ -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;
|