sidekick-shared 0.26.4 → 0.26.6

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 (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +59 -51
  3. package/dist/accountChanges.js +21 -2
  4. package/dist/accountHealth.d.ts +72 -0
  5. package/dist/accountHealth.js +286 -0
  6. package/dist/accountLaunch.d.ts +30 -0
  7. package/dist/accountLaunch.js +124 -0
  8. package/dist/accountManager.d.ts +33 -8
  9. package/dist/accountManager.js +183 -50
  10. package/dist/accountRegistry.d.ts +10 -0
  11. package/dist/accountRegistry.js +42 -22
  12. package/dist/accountSwitch.d.ts +43 -0
  13. package/dist/accountSwitch.js +115 -0
  14. package/dist/accountSync.d.ts +30 -0
  15. package/dist/accountSync.js +180 -0
  16. package/dist/accountSyncTypes.d.ts +25 -0
  17. package/dist/accountSyncTypes.js +7 -0
  18. package/dist/accounts.d.ts +58 -6
  19. package/dist/accounts.js +408 -189
  20. package/dist/claudeCredentials.d.ts +38 -0
  21. package/dist/claudeCredentials.js +74 -0
  22. package/dist/claudeProfiles.d.ts +40 -0
  23. package/dist/claudeProfiles.js +102 -10
  24. package/dist/codexAuth.d.ts +32 -0
  25. package/dist/codexAuth.js +127 -0
  26. package/dist/codexPaths.d.ts +21 -0
  27. package/dist/codexPaths.js +117 -0
  28. package/dist/codexProfiles.d.ts +58 -5
  29. package/dist/codexProfiles.js +574 -289
  30. package/dist/credentialIO.d.ts +12 -5
  31. package/dist/credentialIO.js +63 -29
  32. package/dist/credentials.d.ts +8 -0
  33. package/dist/credentials.js +28 -32
  34. package/dist/ensureDefaultAccounts.d.ts +8 -0
  35. package/dist/ensureDefaultAccounts.js +42 -82
  36. package/dist/index.d.ts +31 -12
  37. package/dist/index.js +68 -13
  38. package/dist/keepAlive.d.ts +44 -0
  39. package/dist/keepAlive.js +106 -0
  40. package/dist/node.d.ts +4 -1
  41. package/dist/node.js +4 -1
  42. package/dist/observedSessionCollector.d.ts +117 -5
  43. package/dist/observedSessionCollector.js +483 -76
  44. package/dist/processDetection.d.ts +32 -0
  45. package/dist/processDetection.js +210 -0
  46. package/dist/providers/claudeCode.d.ts +23 -2
  47. package/dist/providers/claudeCode.js +143 -51
  48. package/dist/providers/codex.d.ts +10 -2
  49. package/dist/providers/codex.js +95 -12
  50. package/dist/providers/directoryListingCache.d.ts +85 -0
  51. package/dist/providers/directoryListingCache.js +244 -0
  52. package/dist/providers/rolloutWalker.d.ts +28 -5
  53. package/dist/providers/rolloutWalker.js +100 -16
  54. package/dist/providers/types.d.ts +48 -1
  55. package/dist/schemas/accountManager.d.ts +283 -1
  56. package/dist/schemas/accountManager.js +122 -1
  57. package/dist/schemas/assistantTurn.d.ts +2 -2
  58. package/dist/schemas/index.d.ts +6 -1
  59. package/dist/schemas/index.js +14 -1
  60. package/dist/schemas/observedSessionV1.d.ts +21 -21
  61. package/dist/schemas/quota.d.ts +18 -18
  62. package/dist/schemas/quotaHistory.d.ts +1 -1
  63. package/dist/schemas/stateFile.d.ts +5 -5
  64. package/dist/sessionMonitor.js +24 -2
  65. package/dist/terminalSync.d.ts +5 -5
  66. package/dist/terminalSync.js +7 -112
  67. package/dist/writers/atomic.d.ts +2 -0
  68. package/dist/writers/atomic.js +49 -2
  69. package/package.json +1 -1
@@ -0,0 +1,32 @@
1
+ export interface RunningProcess {
2
+ pid: number;
3
+ /** The matched name (as passed to the finder). */
4
+ name: string;
5
+ /** The executable as reported by the OS (basename or image name). */
6
+ command: string;
7
+ }
8
+ export type AccountConsumerKind = 'claude-cli' | 'claude-desktop' | 'codex-cli' | 'codex-app' | 'vscode-extension-host';
9
+ export interface RunningAccountConsumer {
10
+ kind: AccountConsumerKind;
11
+ pids: number[];
12
+ /** Whether a live-store switch reaches this consumer once it restarts. */
13
+ switched: boolean;
14
+ /** The user-facing sentence both UIs print for this consumer. */
15
+ reachability: string;
16
+ }
17
+ /** Sync sibling of {@link findRunningProcesses}; blocks up to 4 s. */
18
+ export declare function findRunningProcessesSync(names: string[]): RunningProcess[];
19
+ export declare function findRunningProcesses(names: string[]): Promise<RunningProcess[]>;
20
+ export declare const ACCOUNT_CONSUMER_REACHABILITY: Record<AccountConsumerKind, {
21
+ switched: boolean;
22
+ reachability: string;
23
+ }>;
24
+ /**
25
+ * Which credential consumers are running right now, with the reachability
26
+ * sentence to show for each. Optionally scoped to one provider.
27
+ */
28
+ export declare function detectRunningAccountConsumers(provider?: 'claude-code' | 'codex'): Promise<RunningAccountConsumer[]>;
29
+ /** Sync sibling of {@link detectRunningAccountConsumers}; blocks up to 4 s. */
30
+ export declare function detectRunningAccountConsumersSync(provider?: 'claude-code' | 'codex'): RunningAccountConsumer[];
31
+ /** Build a consumer entry for a kind the host detects itself (e.g. its own extension host). */
32
+ export declare function describeAccountConsumer(kind: AccountConsumerKind, pids?: number[]): RunningAccountConsumer;
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ACCOUNT_CONSUMER_REACHABILITY = void 0;
37
+ exports.findRunningProcessesSync = findRunningProcessesSync;
38
+ exports.findRunningProcesses = findRunningProcesses;
39
+ exports.detectRunningAccountConsumers = detectRunningAccountConsumers;
40
+ exports.detectRunningAccountConsumersSync = detectRunningAccountConsumersSync;
41
+ exports.describeAccountConsumer = describeAccountConsumer;
42
+ /**
43
+ * Cross-platform detection of running CLI/app processes that hold provider
44
+ * credentials in memory. Bounded, never throws, never spawns a shell.
45
+ */
46
+ const child_process_1 = require("child_process");
47
+ const path = __importStar(require("path"));
48
+ const PROBE_TIMEOUT_MS = 4000;
49
+ const CLAUDE_NPM_ENTRY = /claude-code[\\/]cli\.js/;
50
+ function parseUnixTable(stdout) {
51
+ const rows = [];
52
+ for (const line of stdout.split('\n')) {
53
+ const match = line.match(/^\s*(\d+)\s+(\S+)\s*(.*)$/);
54
+ if (!match)
55
+ continue;
56
+ rows.push({ pid: Number(match[1]), command: match[2], args: match[3] ?? '' });
57
+ }
58
+ return rows;
59
+ }
60
+ function parseWindowsCsv(stdout) {
61
+ const rows = [];
62
+ for (const line of stdout.split(/\r?\n/)) {
63
+ const match = line.match(/^"([^"]+)","(\d+)"/);
64
+ if (!match)
65
+ continue;
66
+ rows.push({ pid: Number(match[2]), command: match[1], args: '' });
67
+ }
68
+ return rows;
69
+ }
70
+ function matchRows(rows, names) {
71
+ const wanted = new Set(names);
72
+ const found = [];
73
+ for (const row of rows) {
74
+ if (!Number.isFinite(row.pid) || row.pid === process.pid)
75
+ continue;
76
+ const base = path.basename(row.command);
77
+ // The native Claude Code binary is invoked through a `claude` symlink but
78
+ // its executable is the versioned file (`.../versions/2.1.273`), so `comm`
79
+ // never says `claude`; argv[0] does.
80
+ const argv0 = row.args.trim().split(/\s+/)[0] ?? '';
81
+ const candidates = [base, base.replace(/\.exe$/i, ''), path.basename(argv0)];
82
+ let name = candidates.find((candidate) => candidate && wanted.has(candidate));
83
+ // An npm-installed `claude` runs under `node`; recognise it by its entry file.
84
+ if (!name &&
85
+ wanted.has('claude') &&
86
+ /^node(\.exe)?$/i.test(base) &&
87
+ CLAUDE_NPM_ENTRY.test(row.args)) {
88
+ name = 'claude';
89
+ }
90
+ if (name)
91
+ found.push({ pid: row.pid, name, command: row.command });
92
+ }
93
+ return found;
94
+ }
95
+ function probeCommand() {
96
+ return process.platform === 'win32'
97
+ ? { command: 'tasklist', args: ['/FO', 'CSV', '/NH'] }
98
+ : { command: 'ps', args: ['-axo', 'pid=,comm=,args='] };
99
+ }
100
+ function parseOutput(stdout) {
101
+ return process.platform === 'win32' ? parseWindowsCsv(stdout) : parseUnixTable(stdout);
102
+ }
103
+ /** Sync sibling of {@link findRunningProcesses}; blocks up to 4 s. */
104
+ function findRunningProcessesSync(names) {
105
+ if (names.length === 0)
106
+ return [];
107
+ try {
108
+ const { command, args } = probeCommand();
109
+ const result = (0, child_process_1.spawnSync)(command, args, {
110
+ encoding: 'utf8',
111
+ maxBuffer: 16 * 1024 * 1024,
112
+ timeout: PROBE_TIMEOUT_MS,
113
+ killSignal: 'SIGKILL',
114
+ windowsHide: true,
115
+ });
116
+ if (result.error || result.status !== 0)
117
+ return [];
118
+ return matchRows(parseOutput(String(result.stdout ?? '')), names);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ function findRunningProcesses(names) {
125
+ if (names.length === 0)
126
+ return Promise.resolve([]);
127
+ return new Promise((resolve) => {
128
+ try {
129
+ const { command, args } = probeCommand();
130
+ (0, child_process_1.execFile)(command, args, {
131
+ encoding: 'utf8',
132
+ timeout: PROBE_TIMEOUT_MS,
133
+ killSignal: 'SIGKILL',
134
+ windowsHide: true,
135
+ maxBuffer: 16 * 1024 * 1024,
136
+ }, (error, stdout) => {
137
+ resolve(error ? [] : matchRows(parseOutput(String(stdout ?? '')), names));
138
+ });
139
+ }
140
+ catch {
141
+ resolve([]);
142
+ }
143
+ });
144
+ }
145
+ const CONSUMER_PROCESS_NAMES = {
146
+ 'claude-cli': 'claude',
147
+ 'claude-desktop': 'Claude',
148
+ 'codex-cli': 'codex',
149
+ 'codex-app': 'Codex',
150
+ };
151
+ exports.ACCOUNT_CONSUMER_REACHABILITY = {
152
+ 'claude-cli': {
153
+ switched: true,
154
+ reachability: 'Running claude sessions keep the previous account until restarted; a running session can rotate the refresh token of the account you just left.',
155
+ },
156
+ 'claude-desktop': {
157
+ switched: false,
158
+ reachability: 'Claude Desktop is not switched by sidekick (it keeps its own web session) and its embedded engine can rotate the shared CLI token.',
159
+ },
160
+ 'codex-cli': {
161
+ switched: true,
162
+ reachability: 'Running codex sessions keep the previous account until restarted.',
163
+ },
164
+ 'codex-app': {
165
+ switched: true,
166
+ reachability: 'The Codex app shares the CLI login; restart it to pick up the switch. While running it can refresh and rotate the live token.',
167
+ },
168
+ 'vscode-extension-host': {
169
+ switched: true,
170
+ reachability: 'The Claude Code extension host keeps the previous account until the window reloads.',
171
+ },
172
+ };
173
+ function toConsumer(kind, pids) {
174
+ return { kind, pids, ...exports.ACCOUNT_CONSUMER_REACHABILITY[kind] };
175
+ }
176
+ function groupConsumers(processes, kinds) {
177
+ const consumers = [];
178
+ for (const kind of kinds) {
179
+ const pids = processes.filter((p) => p.name === CONSUMER_PROCESS_NAMES[kind]).map((p) => p.pid);
180
+ if (pids.length > 0)
181
+ consumers.push(toConsumer(kind, pids));
182
+ }
183
+ return consumers;
184
+ }
185
+ function kindsForProvider(provider) {
186
+ if (provider === 'claude-code')
187
+ return ['claude-cli', 'claude-desktop'];
188
+ if (provider === 'codex')
189
+ return ['codex-cli', 'codex-app'];
190
+ return ['claude-cli', 'claude-desktop', 'codex-cli', 'codex-app'];
191
+ }
192
+ /**
193
+ * Which credential consumers are running right now, with the reachability
194
+ * sentence to show for each. Optionally scoped to one provider.
195
+ */
196
+ async function detectRunningAccountConsumers(provider) {
197
+ const kinds = kindsForProvider(provider);
198
+ const processes = await findRunningProcesses(kinds.map((kind) => CONSUMER_PROCESS_NAMES[kind]));
199
+ return groupConsumers(processes, kinds);
200
+ }
201
+ /** Sync sibling of {@link detectRunningAccountConsumers}; blocks up to 4 s. */
202
+ function detectRunningAccountConsumersSync(provider) {
203
+ const kinds = kindsForProvider(provider);
204
+ const processes = findRunningProcessesSync(kinds.map((kind) => CONSUMER_PROCESS_NAMES[kind]));
205
+ return groupConsumers(processes, kinds);
206
+ }
207
+ /** Build a consumer entry for a kind the host detects itself (e.g. its own extension host). */
208
+ function describeAccountConsumer(kind, pids = []) {
209
+ return toConsumer(kind, pids);
210
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import type { ReadSessionContextSnapshotOptions, SessionContextSnapshot } from '../context/sessionContext';
11
11
  import type { SubagentStats, TokenUsage } from '../types/sessionEvent';
12
- import type { SessionProviderBase, SessionReader, SessionFileInfo, SessionFileStats, SearchHit, ProjectFolderInfo, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions } from './types';
12
+ import type { ListSessionFilesOptions, SessionProviderBase, SessionReader, SessionFileInfo, SessionFileStats, SearchHit, ProjectFolderInfo, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions, WatchedSessionFile } from './types';
13
13
  /**
14
14
  * Session provider for Claude Code CLI.
15
15
  *
@@ -23,6 +23,8 @@ export declare class ClaudeCodeProvider implements SessionProviderBase {
23
23
  private readonly diagnostics;
24
24
  /** Runtime-reported context window limit (overrides static map when set). */
25
25
  private dynamicContextWindowLimit;
26
+ /** Project-directory listings reused across async enumerations. */
27
+ private readonly directoryCache;
26
28
  constructor(options?: SessionProviderOptions);
27
29
  getSessionDirectory(workspacePath: string): string;
28
30
  discoverSessionDirectory(workspacePath: string): string | null;
@@ -34,7 +36,26 @@ export declare class ClaudeCodeProvider implements SessionProviderBase {
34
36
  findSessionsInDirectory(dir: string): string[];
35
37
  getAllProjectFolders(workspacePath?: string): ProjectFolderInfo[];
36
38
  listAllSessionFiles(): SessionFileInfo[];
37
- listSessionFilesAsync(workspacePath?: string): Promise<SessionFileInfo[]>;
39
+ /**
40
+ * Enumerate session files across project directories through the
41
+ * directory cache: one stat per project directory, a readdir only for
42
+ * directories whose mtime changed, and a stat only for files new to a
43
+ * changed directory (or recently modified, with `revalidateRecent`).
44
+ *
45
+ * With `limit`, directories are visited newest-mtime first and the walk
46
+ * stops once the remaining directories are older than the newest `limit`
47
+ * files found. A directory's mtime ignores appends, so a cold limited walk
48
+ * can miss a long-running session in an old directory until its next watch
49
+ * event; `since` filters files and shares the caveat.
50
+ */
51
+ listSessionFilesAsync(workspacePath?: string, options?: ListSessionFilesOptions): Promise<SessionFileInfo[]>;
52
+ /**
53
+ * Resolve a recursive-watch event under the projects root with one stat.
54
+ * Sessions live exactly one directory below the root; deeper paths are
55
+ * subagent transcripts and are ignored. A path at the root itself is a
56
+ * project directory appearing or vanishing, which needs a full listing.
57
+ */
58
+ statWatchedSessionFile(root: string, relativePath: string, workspacePath?: string): Promise<WatchedSessionFile>;
38
59
  isSessionFile(filename: string): boolean;
39
60
  getSessionId(sessionPath: string): string;
40
61
  encodeWorkspacePath(workspacePath: string): string;
@@ -51,6 +51,7 @@ const sessionContext_1 = require("../context/sessionContext");
51
51
  const sessionStats_1 = require("../sessionStats");
52
52
  const jsonl_1 = require("../parsers/jsonl");
53
53
  const diagnostics_1 = require("./diagnostics");
54
+ const directoryListingCache_1 = require("./directoryListingCache");
54
55
  const sessionPathResolver_1 = require("../parsers/sessionPathResolver");
55
56
  const subagentScanner_1 = require("../parsers/subagentScanner");
56
57
  const modelContext_1 = require("../modelContext");
@@ -90,6 +91,27 @@ function extractSearchableText(event) {
90
91
  }
91
92
  return '';
92
93
  }
94
+ /**
95
+ * Project-directory filter for a workspace: the encoded workspace path, a
96
+ * worktree suffix of it, or a directory ending in the workspace's basename.
97
+ * Without a workspace every directory matches.
98
+ */
99
+ function workspaceDirectoryFilter(workspacePath) {
100
+ if (!workspacePath)
101
+ return () => true;
102
+ const encodedWorkspace = (0, sessionPathResolver_1.encodeWorkspacePath)(workspacePath).toLowerCase();
103
+ const workspaceBasename = path
104
+ .basename(workspacePath)
105
+ .replace(/[^a-zA-Z0-9]/g, '-')
106
+ .toLowerCase();
107
+ return (directoryName) => {
108
+ const name = directoryName.toLowerCase();
109
+ return (name === encodedWorkspace ||
110
+ name.startsWith(`${encodedWorkspace}-`) ||
111
+ name === workspaceBasename ||
112
+ name.endsWith(`-${workspaceBasename}`));
113
+ };
114
+ }
93
115
  function extractClaudeLabelFromPrefix(chunk) {
94
116
  for (const line of chunk.split('\n')) {
95
117
  const trimmed = line.trim();
@@ -284,6 +306,8 @@ class ClaudeCodeProvider {
284
306
  diagnostics;
285
307
  /** Runtime-reported context window limit (overrides static map when set). */
286
308
  dynamicContextWindowLimit = null;
309
+ /** Project-directory listings reused across async enumerations. */
310
+ directoryCache = new directoryListingCache_1.DirectoryListingCache();
287
311
  constructor(options = {}) {
288
312
  this.diagnostics = new diagnostics_1.ProviderDiagnosticTracker(this.id, options);
289
313
  }
@@ -364,77 +388,144 @@ class ClaudeCodeProvider {
364
388
  this.recordHomeStatus('listAllSessionFiles');
365
389
  return results;
366
390
  }
367
- async listSessionFilesAsync(workspacePath) {
391
+ /**
392
+ * Enumerate session files across project directories through the
393
+ * directory cache: one stat per project directory, a readdir only for
394
+ * directories whose mtime changed, and a stat only for files new to a
395
+ * changed directory (or recently modified, with `revalidateRecent`).
396
+ *
397
+ * With `limit`, directories are visited newest-mtime first and the walk
398
+ * stops once the remaining directories are older than the newest `limit`
399
+ * files found. A directory's mtime ignores appends, so a cold limited walk
400
+ * can miss a long-running session in an old directory until its next watch
401
+ * event; `since` filters files and shares the caveat.
402
+ */
403
+ async listSessionFilesAsync(workspacePath, options = {}) {
368
404
  const projectsRoot = this.getProjectsBaseDir();
369
- let projectEntries = [];
370
- try {
371
- projectEntries = await fs.promises.readdir(projectsRoot, { withFileTypes: true });
405
+ if (options.stats) {
406
+ options.stats.directoriesListed ??= 0;
407
+ options.stats.directoriesStatted ??= 0;
408
+ options.stats.filesStatted ??= 0;
372
409
  }
373
- catch {
410
+ const root = await this.directoryCache.listDirectory(projectsRoot, () => false, {
411
+ revalidateRecent: options.revalidateRecent,
412
+ stats: options.stats,
413
+ });
414
+ if (!root) {
374
415
  this.recordHomeStatus('listSessionFilesAsync');
375
416
  return [];
376
417
  }
377
- const encodedWorkspace = workspacePath ? (0, sessionPathResolver_1.encodeWorkspacePath)(workspacePath).toLowerCase() : null;
378
- const workspaceBasename = workspacePath
379
- ? path
380
- .basename(workspacePath)
381
- .replace(/[^a-zA-Z0-9]/g, '-')
382
- .toLowerCase()
418
+ const limit = options.limit !== undefined && Number.isFinite(options.limit)
419
+ ? Math.max(0, Math.floor(options.limit))
383
420
  : null;
384
- const directories = [];
385
- for (const entry of projectEntries) {
386
- const directory = path.join(projectsRoot, entry.name);
421
+ if (limit === 0) {
422
+ this.recordHomeStatus('listSessionFilesAsync');
423
+ return [];
424
+ }
425
+ const since = options.since !== undefined && Number.isFinite(options.since) ? options.since : null;
426
+ const matchesWorkspace = workspaceDirectoryFilter(workspacePath);
427
+ const candidates = [];
428
+ let sinceYield = 0;
429
+ for (const name of root.subdirectories) {
430
+ if (!matchesWorkspace(name))
431
+ continue;
432
+ const directory = path.join(projectsRoot, name);
387
433
  try {
388
- const stat = entry.isDirectory() ? null : await fs.promises.stat(directory);
389
- if (!entry.isDirectory() && !stat?.isDirectory())
390
- continue;
434
+ const stat = await fs.promises.stat(directory);
435
+ if (options.stats) {
436
+ options.stats.directoriesStatted = (options.stats.directoriesStatted ?? 0) + 1;
437
+ }
438
+ if (stat.isDirectory())
439
+ candidates.push({ directory, stat });
391
440
  }
392
441
  catch {
393
442
  continue;
394
443
  }
395
- const name = entry.name.toLowerCase();
396
- if (encodedWorkspace &&
397
- name !== encodedWorkspace &&
398
- !name.startsWith(`${encodedWorkspace}-`) &&
399
- name !== workspaceBasename &&
400
- !name.endsWith(`-${workspaceBasename}`)) {
444
+ if (++sinceYield >= 50) {
445
+ sinceYield = 0;
446
+ await new Promise((resolve) => setImmediate(resolve));
447
+ }
448
+ }
449
+ if (limit !== null)
450
+ candidates.sort((left, right) => right.stat.mtimeMs - left.stat.mtimeMs);
451
+ const byNewest = (left, right) => right.mtime.getTime() - left.mtime.getTime();
452
+ const results = [];
453
+ let cutoffMs = null;
454
+ for (const candidate of candidates) {
455
+ if (limit !== null && cutoffMs !== null && candidate.stat.mtimeMs < cutoffMs)
456
+ break;
457
+ const listing = await this.directoryCache.listDirectory(candidate.directory, (name) => this.isSessionFile(name), {
458
+ dirStat: candidate.stat,
459
+ revalidateRecent: options.revalidateRecent,
460
+ stats: options.stats,
461
+ });
462
+ if (!listing)
401
463
  continue;
464
+ for (const [name, stat] of listing.files) {
465
+ if (stat.sizeBytes <= 0)
466
+ continue;
467
+ if (since !== null && stat.mtimeMs < since)
468
+ continue;
469
+ const sessionPath = path.join(candidate.directory, name);
470
+ results.push({
471
+ path: sessionPath,
472
+ mtime: new Date(stat.mtimeMs),
473
+ sizeBytes: stat.sizeBytes,
474
+ sessionId: this.getSessionId(sessionPath),
475
+ });
476
+ }
477
+ if (limit !== null && results.length >= limit) {
478
+ results.sort(byNewest);
479
+ results.length = limit;
480
+ cutoffMs = results[limit - 1].mtime.getTime();
402
481
  }
403
- directories.push(directory);
404
482
  await new Promise((resolve) => setImmediate(resolve));
405
483
  }
406
- const results = [];
407
- for (const directory of directories) {
408
- let entries = [];
484
+ this.recordHomeStatus('listSessionFilesAsync');
485
+ results.sort(byNewest);
486
+ return limit !== null && results.length > limit ? results.slice(0, limit) : results;
487
+ }
488
+ /**
489
+ * Resolve a recursive-watch event under the projects root with one stat.
490
+ * Sessions live exactly one directory below the root; deeper paths are
491
+ * subagent transcripts and are ignored. A path at the root itself is a
492
+ * project directory appearing or vanishing, which needs a full listing.
493
+ */
494
+ async statWatchedSessionFile(root, relativePath, workspacePath) {
495
+ const segments = (0, directoryListingCache_1.splitWatchedPath)(relativePath);
496
+ if (!segments)
497
+ return { status: 'unknown' };
498
+ if (segments.length > 2)
499
+ return { status: 'ignored' };
500
+ const fullPath = path.join(root, ...segments);
501
+ if (segments.length === 1) {
502
+ this.directoryCache.invalidate(fullPath);
409
503
  try {
410
- entries = await fs.promises.readdir(directory, { withFileTypes: true });
504
+ const stat = await fs.promises.stat(fullPath);
505
+ return stat.isDirectory() ? { status: 'unknown' } : { status: 'ignored' };
411
506
  }
412
507
  catch {
413
- continue;
414
- }
415
- for (const entry of entries) {
416
- if (!entry.isFile() || !this.isSessionFile(entry.name))
417
- continue;
418
- const sessionPath = path.join(directory, entry.name);
419
- try {
420
- const stat = await fs.promises.stat(sessionPath);
421
- if (stat.size > 0) {
422
- results.push({
423
- path: sessionPath,
424
- mtime: stat.mtime,
425
- sizeBytes: stat.size,
426
- sessionId: this.getSessionId(sessionPath),
427
- });
428
- }
429
- }
430
- catch {
431
- // Skip files that vanish between discovery and stat.
432
- }
433
- await new Promise((resolve) => setImmediate(resolve));
508
+ return { status: 'unknown' };
434
509
  }
435
510
  }
436
- this.recordHomeStatus('listSessionFilesAsync');
437
- return results.sort((left, right) => right.mtime.getTime() - left.mtime.getTime());
511
+ const [directoryName, fileName] = segments;
512
+ if (!this.isSessionFile(fileName))
513
+ return { status: 'ignored' };
514
+ if (!workspaceDirectoryFilter(workspacePath)(directoryName))
515
+ return { status: 'ignored' };
516
+ const sessionId = this.getSessionId(fullPath);
517
+ const stat = await this.directoryCache.refreshFile(fullPath);
518
+ if (!stat || stat.sizeBytes <= 0)
519
+ return { status: 'missing', path: fullPath, sessionId };
520
+ return {
521
+ status: 'present',
522
+ file: {
523
+ path: fullPath,
524
+ mtime: new Date(stat.mtimeMs),
525
+ sizeBytes: stat.sizeBytes,
526
+ sessionId,
527
+ },
528
+ };
438
529
  }
439
530
  // --- File identification ---
440
531
  isSessionFile(filename) {
@@ -627,6 +718,7 @@ class ClaudeCodeProvider {
627
718
  // --- Lifecycle ---
628
719
  dispose() {
629
720
  this.dynamicContextWindowLimit = null;
721
+ this.directoryCache.invalidate();
630
722
  }
631
723
  recordHomeStatus(operation) {
632
724
  const status = this.getRuntimeStatus();
@@ -9,7 +9,7 @@
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, SessionFileInfo, SessionFileStats, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions } from './types';
12
+ import type { ListSessionFilesOptions, SessionProviderBase, SessionReader, ProjectFolderInfo, SearchHit, SessionFileInfo, SessionFileStats, ProviderId, ProviderOperationStatus, ProviderRuntimeStatus, SessionProviderOptions, WatchedSessionFile } from './types';
13
13
  import type { SubagentStats, TokenUsage } from '../types/sessionEvent';
14
14
  import type { CodexRateLimits } from '../types/codex';
15
15
  export interface FindCodexRolloutFileOptions {
@@ -48,6 +48,8 @@ export declare class CodexProvider implements SessionProviderBase {
48
48
  private dynamicContextWindowLimit;
49
49
  private lastRateLimitsData;
50
50
  private lastTokenUsageData;
51
+ /** Dated rollout-directory listings reused across async enumerations. */
52
+ private readonly directoryCache;
51
53
  constructor(options?: SessionProviderOptions);
52
54
  /** Lazy-initialize database connection. */
53
55
  private ensureDb;
@@ -69,7 +71,13 @@ export declare class CodexProvider implements SessionProviderBase {
69
71
  findSessionFiles(workspacePath: string): string[];
70
72
  findSessionsInDirectory(dir: string): string[];
71
73
  listAllSessionFiles(): SessionFileInfo[];
72
- listSessionFilesAsync(workspacePath?: string): Promise<SessionFileInfo[]>;
74
+ listSessionFilesAsync(workspacePath?: string, options?: ListSessionFilesOptions): Promise<SessionFileInfo[]>;
75
+ /**
76
+ * Resolve a recursive-watch event under a sessions root with one stat.
77
+ * Rollouts are matched by name at any depth the walker would visit; a
78
+ * dated directory appearing or vanishing needs a full walk.
79
+ */
80
+ statWatchedSessionFile(root: string, relativePath: string, workspacePath?: string): Promise<WatchedSessionFile>;
73
81
  getAllProjectFolders(workspacePath?: string): ProjectFolderInfo[];
74
82
  isSessionFile(filename: string): boolean;
75
83
  getSessionId(sessionPath: string): string;