@gitkraken/core-gitlens 0.5.113 → 0.5.114

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 (241) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/git/cache.d.ts +4 -0
  3. package/dist/git/cache.d.ts.map +1 -1
  4. package/dist/git/cache.js +33 -0
  5. package/dist/git/cache.js.map +1 -1
  6. package/dist/git/context.d.ts +10 -0
  7. package/dist/git/context.d.ts.map +1 -1
  8. package/dist/git/context.js.map +1 -1
  9. package/dist/git/errors.d.ts +6 -3
  10. package/dist/git/errors.d.ts.map +1 -1
  11. package/dist/git/errors.js +9 -1
  12. package/dist/git/errors.js.map +1 -1
  13. package/dist/git/features.d.ts +1 -1
  14. package/dist/git/features.d.ts.map +1 -1
  15. package/dist/git/features.js +38 -0
  16. package/dist/git/features.js.map +1 -1
  17. package/dist/git/gitHealth.d.ts +153 -0
  18. package/dist/git/gitHealth.d.ts.map +1 -0
  19. package/dist/git/gitHealth.js +353 -0
  20. package/dist/git/gitHealth.js.map +1 -0
  21. package/dist/git/models/search.d.ts +6 -3
  22. package/dist/git/models/search.d.ts.map +1 -1
  23. package/dist/git/models/search.js +5 -1
  24. package/dist/git/models/search.js.map +1 -1
  25. package/dist/git/parsers/diffParser.d.ts +8 -2
  26. package/dist/git/parsers/diffParser.d.ts.map +1 -1
  27. package/dist/git/parsers/diffParser.js +27 -4
  28. package/dist/git/parsers/diffParser.js.map +1 -1
  29. package/dist/git/providers/config.d.ts +36 -3
  30. package/dist/git/providers/config.d.ts.map +1 -1
  31. package/dist/git/providers/graph.d.ts +9 -0
  32. package/dist/git/providers/graph.d.ts.map +1 -1
  33. package/dist/git/providers/maintenance.d.ts +222 -0
  34. package/dist/git/providers/maintenance.d.ts.map +1 -0
  35. package/dist/git/providers/maintenance.js +20 -0
  36. package/dist/git/providers/maintenance.js.map +1 -0
  37. package/dist/git/providers/operations.d.ts +10 -0
  38. package/dist/git/providers/operations.d.ts.map +1 -1
  39. package/dist/git/providers/pausedOperations.d.ts +3 -0
  40. package/dist/git/providers/pausedOperations.d.ts.map +1 -1
  41. package/dist/git/providers/provider.d.ts +2 -0
  42. package/dist/git/providers/provider.d.ts.map +1 -1
  43. package/dist/git/providers/tags.d.ts +3 -0
  44. package/dist/git/providers/tags.d.ts.map +1 -1
  45. package/dist/git/repositoryService.d.ts +1 -0
  46. package/dist/git/repositoryService.d.ts.map +1 -1
  47. package/dist/git/repositoryService.js.map +1 -1
  48. package/dist/git/run.types.d.ts +8 -0
  49. package/dist/git/run.types.d.ts.map +1 -1
  50. package/dist/git/service.js +3 -0
  51. package/dist/git/service.js.map +1 -1
  52. package/dist/git/utils/search.utils.d.ts.map +1 -1
  53. package/dist/git/utils/search.utils.js +112 -5
  54. package/dist/git/utils/search.utils.js.map +1 -1
  55. package/dist/git/watching/watchService.d.ts +3 -0
  56. package/dist/git/watching/watchService.d.ts.map +1 -1
  57. package/dist/git/watching/watchService.js +8 -0
  58. package/dist/git/watching/watchService.js.map +1 -1
  59. package/dist/git-cli/cliGitProvider.d.ts +11 -0
  60. package/dist/git-cli/cliGitProvider.d.ts.map +1 -1
  61. package/dist/git-cli/cliGitProvider.js +12 -0
  62. package/dist/git-cli/cliGitProvider.js.map +1 -1
  63. package/dist/git-cli/exec/git.d.ts +23 -1
  64. package/dist/git-cli/exec/git.d.ts.map +1 -1
  65. package/dist/git-cli/exec/git.js +83 -8
  66. package/dist/git-cli/exec/git.js.map +1 -1
  67. package/dist/git-cli/exec/gitQueue.d.ts +2 -0
  68. package/dist/git-cli/exec/gitQueue.d.ts.map +1 -1
  69. package/dist/git-cli/exec/gitQueue.js +19 -17
  70. package/dist/git-cli/exec/gitQueue.js.map +1 -1
  71. package/dist/git-cli/parsers/logParser.d.ts.map +1 -1
  72. package/dist/git-cli/parsers/logParser.js +4 -1
  73. package/dist/git-cli/parsers/logParser.js.map +1 -1
  74. package/dist/git-cli/providers/blame.d.ts.map +1 -1
  75. package/dist/git-cli/providers/blame.js +4 -7
  76. package/dist/git-cli/providers/blame.js.map +1 -1
  77. package/dist/git-cli/providers/branches.d.ts.map +1 -1
  78. package/dist/git-cli/providers/branches.js +13 -3
  79. package/dist/git-cli/providers/branches.js.map +1 -1
  80. package/dist/git-cli/providers/commits.d.ts.map +1 -1
  81. package/dist/git-cli/providers/commits.js +34 -3
  82. package/dist/git-cli/providers/commits.js.map +1 -1
  83. package/dist/git-cli/providers/config.d.ts +27 -0
  84. package/dist/git-cli/providers/config.d.ts.map +1 -1
  85. package/dist/git-cli/providers/config.js +31 -6
  86. package/dist/git-cli/providers/config.js.map +1 -1
  87. package/dist/git-cli/providers/graph.d.ts +8 -0
  88. package/dist/git-cli/providers/graph.d.ts.map +1 -1
  89. package/dist/git-cli/providers/graph.js +85 -6
  90. package/dist/git-cli/providers/graph.js.map +1 -1
  91. package/dist/git-cli/providers/maintenance.d.ts +306 -0
  92. package/dist/git-cli/providers/maintenance.d.ts.map +1 -0
  93. package/dist/git-cli/providers/maintenance.js +2129 -0
  94. package/dist/git-cli/providers/maintenance.js.map +1 -0
  95. package/dist/git-cli/providers/operations.d.ts +1 -0
  96. package/dist/git-cli/providers/operations.d.ts.map +1 -1
  97. package/dist/git-cli/providers/operations.js +8 -0
  98. package/dist/git-cli/providers/operations.js.map +1 -1
  99. package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
  100. package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
  101. package/dist/git-cli/providers/pausedOperations.js +1 -1
  102. package/dist/git-cli/providers/pausedOperations.js.map +1 -1
  103. package/dist/git-cli/providers/tags.d.ts +3 -0
  104. package/dist/git-cli/providers/tags.d.ts.map +1 -1
  105. package/dist/git-cli/providers/tags.js +23 -1
  106. package/dist/git-cli/providers/tags.js.map +1 -1
  107. package/dist/plus/agents/agentCapabilities.d.ts +77 -0
  108. package/dist/plus/agents/agentCapabilities.d.ts.map +1 -0
  109. package/dist/plus/agents/agentCapabilities.js +197 -0
  110. package/dist/plus/agents/agentCapabilities.js.map +1 -0
  111. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts +77 -13
  112. package/dist/plus/agents/providers/claudeCodeTranscript.d.ts.map +1 -1
  113. package/dist/plus/agents/providers/claudeCodeTranscript.js +263 -55
  114. package/dist/plus/agents/providers/claudeCodeTranscript.js.map +1 -1
  115. package/dist/plus/agents/providers/{claudeCodeProvider.d.ts → gkAgentProvider.d.ts} +52 -19
  116. package/dist/plus/agents/providers/gkAgentProvider.d.ts.map +1 -0
  117. package/dist/plus/agents/providers/{claudeCodeProvider.js → gkAgentProvider.js} +935 -262
  118. package/dist/plus/agents/providers/gkAgentProvider.js.map +1 -0
  119. package/dist/plus/agents/types.d.ts +113 -35
  120. package/dist/plus/agents/types.d.ts.map +1 -1
  121. package/dist/plus/agents/types.js +7 -4
  122. package/dist/plus/agents/types.js.map +1 -1
  123. package/dist/plus/ai/models/promptTemplates.d.ts +1 -1
  124. package/dist/plus/ai/models/promptTemplates.d.ts.map +1 -1
  125. package/dist/plus/ai/prompts.d.ts +4 -0
  126. package/dist/plus/ai/prompts.d.ts.map +1 -1
  127. package/dist/plus/ai/prompts.js +59 -4
  128. package/dist/plus/ai/prompts.js.map +1 -1
  129. package/dist/plus/git-github/api/github.d.ts +44 -1
  130. package/dist/plus/git-github/api/github.d.ts.map +1 -1
  131. package/dist/plus/git-github/api/github.js +318 -25
  132. package/dist/plus/git-github/api/github.js.map +1 -1
  133. package/dist/plus/git-github/api/issueSearchQuery.d.ts +13 -0
  134. package/dist/plus/git-github/api/issueSearchQuery.d.ts.map +1 -1
  135. package/dist/plus/git-github/api/issueSearchQuery.js +41 -0
  136. package/dist/plus/git-github/api/issueSearchQuery.js.map +1 -1
  137. package/dist/plus/git-github/providers/github/blame.d.ts.map +1 -1
  138. package/dist/plus/git-github/providers/github/blame.js +2 -1
  139. package/dist/plus/git-github/providers/github/blame.js.map +1 -1
  140. package/dist/plus/integrations/integrationService.d.ts +11 -0
  141. package/dist/plus/integrations/integrationService.d.ts.map +1 -1
  142. package/dist/plus/integrations/integrationService.js +4 -0
  143. package/dist/plus/integrations/integrationService.js.map +1 -1
  144. package/dist/plus/integrations/manager.d.ts +26 -0
  145. package/dist/plus/integrations/manager.d.ts.map +1 -1
  146. package/dist/plus/integrations/models/gitHostIntegration.d.ts +31 -0
  147. package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
  148. package/dist/plus/integrations/models/gitHostIntegration.js +31 -0
  149. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  150. package/dist/plus/integrations/models/integration.d.ts +1 -1
  151. package/dist/plus/integrations/models/integration.d.ts.map +1 -1
  152. package/dist/plus/integrations/models/integration.js.map +1 -1
  153. package/dist/plus/integrations/providers/github.d.ts +9 -0
  154. package/dist/plus/integrations/providers/github.d.ts.map +1 -1
  155. package/dist/plus/integrations/providers/github.js +7 -0
  156. package/dist/plus/integrations/providers/github.js.map +1 -1
  157. package/dist/plus/integrations/reads/broaden.d.ts +47 -10
  158. package/dist/plus/integrations/reads/broaden.d.ts.map +1 -1
  159. package/dist/plus/integrations/reads/broaden.js +144 -33
  160. package/dist/plus/integrations/reads/broaden.js.map +1 -1
  161. package/dist/plus/integrations/reads/filters.d.ts +18 -0
  162. package/dist/plus/integrations/reads/filters.d.ts.map +1 -1
  163. package/dist/plus/integrations/reads/filters.js +37 -1
  164. package/dist/plus/integrations/reads/filters.js.map +1 -1
  165. package/dist/plus/integrations/reads/issueBatch.d.ts +51 -0
  166. package/dist/plus/integrations/reads/issueBatch.d.ts.map +1 -0
  167. package/dist/plus/integrations/reads/issueBatch.js +91 -0
  168. package/dist/plus/integrations/reads/issueBatch.js.map +1 -0
  169. package/dist/plus/integrations/reads/sweeps.d.ts.map +1 -1
  170. package/dist/plus/integrations/reads/sweeps.js +5 -3
  171. package/dist/plus/integrations/reads/sweeps.js.map +1 -1
  172. package/dist/utils/promiseCache.d.ts +19 -0
  173. package/dist/utils/promiseCache.d.ts.map +1 -1
  174. package/dist/utils/promiseCache.js +30 -0
  175. package/dist/utils/promiseCache.js.map +1 -1
  176. package/dist/utils/resourceUsage.d.ts +5 -0
  177. package/dist/utils/resourceUsage.d.ts.map +1 -0
  178. package/dist/utils/resourceUsage.js +2 -0
  179. package/dist/utils/resourceUsage.js.map +1 -0
  180. package/dist/utils/string.d.ts +12 -0
  181. package/dist/utils/string.d.ts.map +1 -1
  182. package/dist/utils/string.js +36 -1
  183. package/dist/utils/string.js.map +1 -1
  184. package/docs/integrations.md +29 -25
  185. package/docs/kepler-read-api-parity.md +12 -4
  186. package/package.json +11 -3
  187. package/src/git/cache.ts +36 -0
  188. package/src/git/context.ts +11 -0
  189. package/src/git/errors.ts +13 -1
  190. package/src/git/features.ts +51 -0
  191. package/src/git/gitHealth.ts +522 -0
  192. package/src/git/models/search.ts +19 -4
  193. package/src/git/parsers/diffParser.ts +27 -4
  194. package/src/git/providers/config.ts +42 -1
  195. package/src/git/providers/graph.ts +12 -0
  196. package/src/git/providers/maintenance.ts +224 -0
  197. package/src/git/providers/operations.ts +10 -0
  198. package/src/git/providers/pausedOperations.ts +3 -0
  199. package/src/git/providers/provider.ts +2 -0
  200. package/src/git/providers/tags.ts +1 -0
  201. package/src/git/repositoryService.ts +1 -0
  202. package/src/git/run.types.ts +8 -0
  203. package/src/git/service.ts +3 -0
  204. package/src/git/utils/search.utils.ts +129 -5
  205. package/src/git/watching/watchService.ts +10 -0
  206. package/src/git-cli/cliGitProvider.ts +25 -0
  207. package/src/git-cli/exec/git.ts +120 -6
  208. package/src/git-cli/exec/gitQueue.ts +19 -17
  209. package/src/git-cli/parsers/logParser.ts +4 -1
  210. package/src/git-cli/providers/blame.ts +3 -8
  211. package/src/git-cli/providers/branches.ts +28 -8
  212. package/src/git-cli/providers/commits.ts +34 -2
  213. package/src/git-cli/providers/config.ts +38 -6
  214. package/src/git-cli/providers/graph.ts +107 -7
  215. package/src/git-cli/providers/maintenance.ts +2599 -0
  216. package/src/git-cli/providers/operations.ts +11 -0
  217. package/src/git-cli/providers/pausedOperations.ts +7 -2
  218. package/src/git-cli/providers/tags.ts +36 -1
  219. package/src/plus/agents/agentCapabilities.ts +286 -0
  220. package/src/plus/agents/providers/claudeCodeTranscript.ts +313 -58
  221. package/src/plus/agents/providers/{claudeCodeProvider.ts → gkAgentProvider.ts} +1123 -277
  222. package/src/plus/agents/types.ts +137 -40
  223. package/src/plus/ai/models/promptTemplates.ts +1 -1
  224. package/src/plus/ai/prompts.ts +62 -4
  225. package/src/plus/git-github/api/github.ts +353 -24
  226. package/src/plus/git-github/api/issueSearchQuery.ts +49 -1
  227. package/src/plus/git-github/providers/github/blame.ts +2 -1
  228. package/src/plus/integrations/integrationService.ts +15 -0
  229. package/src/plus/integrations/manager.ts +26 -0
  230. package/src/plus/integrations/models/gitHostIntegration.ts +48 -0
  231. package/src/plus/integrations/models/integration.ts +1 -0
  232. package/src/plus/integrations/providers/github.ts +18 -0
  233. package/src/plus/integrations/reads/broaden.ts +282 -70
  234. package/src/plus/integrations/reads/filters.ts +39 -1
  235. package/src/plus/integrations/reads/issueBatch.ts +197 -0
  236. package/src/plus/integrations/reads/sweeps.ts +19 -12
  237. package/src/utils/promiseCache.ts +43 -0
  238. package/src/utils/resourceUsage.ts +5 -0
  239. package/src/utils/string.ts +38 -1
  240. package/dist/plus/agents/providers/claudeCodeProvider.d.ts.map +0 -1
  241. package/dist/plus/agents/providers/claudeCodeProvider.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { createReadStream } from 'fs';
2
- import { open, readdir, stat } from 'fs/promises';
2
+ import { open, readdir, readFile, stat } from 'fs/promises';
3
3
  import { homedir } from 'os';
4
- import { basename, join } from 'path';
4
+ import { basename, dirname, join } from 'path';
5
5
  import { createInterface } from 'readline';
6
6
 
7
7
  export interface TranscriptTitles {
@@ -31,7 +31,18 @@ export interface TranscriptSessionListing {
31
31
  readonly total: number;
32
32
  }
33
33
 
34
- export interface CompletedTranscriptDetails {
34
+ /** A transcript found outside the queried cwd's project directory, plus the directory Claude
35
+ * actually uses to resolve it for `--resume`. */
36
+ export interface ResumableTranscriptSessionSummary extends TranscriptSessionSummary {
37
+ readonly cwd: string;
38
+ }
39
+
40
+ export interface ResumableTranscriptSessionListing {
41
+ readonly sessions: ResumableTranscriptSessionSummary[];
42
+ readonly total: number;
43
+ }
44
+
45
+ export interface EndedTranscriptDetails {
35
46
  titles: TranscriptTitles;
36
47
  firstPrompt?: string;
37
48
  lastPrompt?: string;
@@ -50,7 +61,13 @@ interface ListingCacheEntry {
50
61
  resolvedAt: number;
51
62
  }
52
63
 
64
+ interface NameCacheEntry {
65
+ names: string[];
66
+ resolvedAt: number;
67
+ }
68
+
53
69
  interface SummaryCacheEntry {
70
+ path: string;
54
71
  mtimeMs: number;
55
72
  size: number;
56
73
  summary: TranscriptSessionSummary;
@@ -72,12 +89,12 @@ const summaryWindowSize = 64 * 1024;
72
89
  * Time-based rather than dir-mtime-based because appends move file mtimes without touching the dir. */
73
90
  const listingCacheTtlMs = 10 * 1000;
74
91
  /** Summaries are keyed by session, and a busy project has hundreds — unlike the per-live-session
75
- * title cache, this needs a ceiling. */
76
- const summaryCacheLimit = 200;
92
+ * title cache, this needs a ceiling. Sized to hold a whole junk-heavy store: the listing scan runs
93
+ * to exhaustion, and each entry is tiny (a path plus a few short strings), so a cap below the
94
+ * store's size would evict-and-re-read the junk tail on every listing instead of paying its
95
+ * windowed reads once. */
96
+ const summaryCacheLimit = 1000;
77
97
  const defaultListLimit = 50;
78
- /** How far past `limit` the top-up scan may read when transcripts turn out empty — a junk-filled
79
- * store reads at most `limit + listScanSlack` summaries, staying well under {@link summaryCacheLimit}. */
80
- const listScanSlack = 25;
81
98
 
82
99
  /**
83
100
  * Reads Claude Code transcript JSONL files at `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`
@@ -95,9 +112,33 @@ export class ClaudeCodeTranscriptReader {
95
112
  private readonly _cache = new Map<string, CacheEntry>();
96
113
  private readonly _generations = new Map<string, number>();
97
114
  private readonly _listings = new Map<string, ListingCacheEntry>();
115
+ /** Transcript file names per project directory — the readdir behind both the plain listing and
116
+ * the by-id scan, which sweeps every project directory. */
117
+ private readonly _names = new Map<string, NameCacheEntry>();
118
+ /** Project directories under {@link getProjectsRoot}, re-scanned by every by-id recovery. */
119
+ private _projectDirs: { dirs: string[]; resolvedAt: number } | undefined;
98
120
  /** Insertion-ordered LRU — re-inserted on hit, oldest key evicted past {@link summaryCacheLimit}. */
99
121
  private readonly _summaries = new Map<string, SummaryCacheEntry>();
122
+ /** Resolved project path per transcript path, bounded like {@link _summaries}. */
123
+ private readonly _transcriptCwds = new Map<string, string>();
100
124
  private _nextGen = 0;
125
+ /** Bumped by {@link invalidateListings}; async listing scans capture it at entry and skip their
126
+ * cache write when it moved, so a stale in-flight result can't re-seed a just-cleared cache. */
127
+ private _listingGen = 0;
128
+
129
+ /** Drops the directory-listing caches (entries, names, project dirs) so the next listing re-reads
130
+ * the store. Called when a session ends or is removed: its transcript may have appeared inside
131
+ * the TTL window, and a cached listing would hide the new file from the very fetch meant to
132
+ * surface it. Summary and cwd caches are keyed by file identity and stay valid, so they're kept.
133
+ * The generation bump makes any in-flight listing skip its own cache write — otherwise a scan
134
+ * started before the invalidation would re-seed the caches with pre-end results, freshly
135
+ * timestamped (same discipline as `resolve`'s per-session generations). */
136
+ invalidateListings(): void {
137
+ this._listingGen++;
138
+ this._listings.clear();
139
+ this._names.clear();
140
+ this._projectDirs = undefined;
141
+ }
101
142
 
102
143
  async resolve(sessionId: string, cwd: string | undefined): Promise<TranscriptTitles | undefined> {
103
144
  const gen = ++this._nextGen;
@@ -155,9 +196,9 @@ export class ClaudeCodeTranscriptReader {
155
196
  }
156
197
 
157
198
  /**
158
- * One-shot read for a *completed* (terminal, static) session: extracts the transcript titles plus
199
+ * One-shot read for an *ended* (terminal, static) session: extracts the transcript titles plus
159
200
  * the first and last user prompts. Unlike {@link resolve}, it bypasses the tail cache — a finished
160
- * transcript never grows, so it's read once on demand (when a completed row is opened) and the
201
+ * transcript never grows, so it's read once on demand (when an ended row is opened) and the
161
202
  * result applied to the session. `last-prompt` entries carry the prompt as of that point; the first
162
203
  * one bearing a value is the session's opening prompt, the last its most recent. Returns `undefined`
163
204
  * when no transcript is found (archived/purged) — the caller keeps whatever durable-store fields it
@@ -169,10 +210,7 @@ export class ClaudeCodeTranscriptReader {
169
210
  * past any fixed head window, and a windowed read would then mistake a later prompt for the first,
170
211
  * poisoning the derived session name.
171
212
  */
172
- async resolveCompletedDetails(
173
- sessionId: string,
174
- cwd: string | undefined,
175
- ): Promise<CompletedTranscriptDetails | undefined> {
213
+ async resolveEndedDetails(sessionId: string, cwd: string | undefined): Promise<EndedTranscriptDetails | undefined> {
176
214
  const path = await this.locateTranscript(sessionId, cwd);
177
215
  if (path == null) return undefined;
178
216
 
@@ -213,16 +251,17 @@ export class ClaudeCodeTranscriptReader {
213
251
 
214
252
  /**
215
253
  * Lists the transcripts of sessions whose working directory is `cwd`, most-recently-active first,
216
- * summarizing until `limit` summarizable transcripts are found (bounded by a small scan slack)
217
- * not just the first `limit` on disk, since junk transcripts (dropped below) would otherwise starve
218
- * the result. `excludeSessionIds` is skipped before `limit` applies, and excluded entries are also
219
- * dropped from `total` — they're already shown elsewhere (e.g. as live sessions), so a caller's
220
- * "N of M" count must not double-count them.
254
+ * summarizing until `limit` summarizable transcripts are found not just the first `limit` on
255
+ * disk, since junk transcripts (dropped below) would otherwise starve the result.
256
+ * `excludeSessionIds` is skipped before `limit` applies, and excluded entries are also dropped
257
+ * from `total` — they're already shown elsewhere (e.g. as live sessions), so a caller's "N of M"
258
+ * count must not double-count them.
221
259
  *
222
- * Claude homes a transcript under the directory encoding the session's *current* cwd, migrating the
223
- * file if the session `cd`s — so this directory is exactly the set `claude --resume <id>` can find
224
- * when invoked from `cwd`, and every entry is resumable from there. The transcripts' own recorded
225
- * `cwd` is per-message and lags the move, so it must NOT be used to filter; the directory decides.
260
+ * Claude homes a transcript under the directory encoding the cwd from which the session started.
261
+ * That directory is authoritative for where `claude --resume <id>` can find the transcript, but
262
+ * Claude does not reliably move it when the session later changes worktrees. Callers that have a
263
+ * durable session-to-worktree association can recover those moved sessions with
264
+ * {@link listSessionsByIds}; this method intentionally remains the cheap legacy directory listing.
226
265
  *
227
266
  * Discovery (readdir + stat) covers the whole directory and is cheap; summarizing is not, so it's
228
267
  * capped. Entries whose summary yields neither a title nor a prompt are dropped — those are aborted
@@ -240,37 +279,51 @@ export class ClaudeCodeTranscriptReader {
240
279
 
241
280
  const limit = options?.limit ?? defaultListLimit;
242
281
  const exclude = options?.excludeSessionIds;
243
- const candidates = exclude?.size ? entries.filter(e => !exclude.has(e.sessionId)) : entries;
244
-
245
- const sessions: TranscriptSessionSummary[] = [];
246
- const pushSummaries = (settled: PromiseSettledResult<TranscriptSessionSummary | undefined>[]): void => {
247
- for (const result of settled) {
248
- if (result.status !== 'fulfilled') continue;
282
+ // Zero-byte transcripts can never summarize skip the read entirely and keep them out of
283
+ // `total`, which otherwise counts sessions no `limit` could ever surface.
284
+ const candidates = entries.filter(e => e.size > 0 && exclude?.has(e.sessionId) !== true);
285
+ const { sessions, exhausted } = await this.collectSessions(candidates, limit, async entry => {
286
+ const summary = await this.resolveSummary(entry);
287
+ return summary != null && hasSummaryContent(summary) ? summary : undefined;
288
+ });
249
289
 
250
- const summary = result.value;
251
- if (summary == null || !hasSummaryContent(summary)) continue;
290
+ // An exhausted scan proved the exact valid count; otherwise unscanned candidates keep
291
+ // `total` an upper bound so paging knows more MAY exist.
292
+ return { sessions: sessions, total: exhausted ? sessions.length : candidates.length };
293
+ }
252
294
 
253
- sessions.push(summary);
254
- }
255
- };
295
+ /**
296
+ * Finds a known set of session ids across every Claude project directory except `excludeCwd`'s.
297
+ * This is the recovery path for durable CLI records whose worktree changed after Claude chose the
298
+ * transcript's project directory. The caller supplies the association; transcript contents alone
299
+ * are deliberately not used to guess which current worktree owns an arbitrary old session.
300
+ *
301
+ * Each result carries the project path encoded by the directory that actually holds the transcript,
302
+ * so resuming it does not repeat discovery from the wrong worktree. Duplicate ids are counted once,
303
+ * with the newest transcript winning.
304
+ */
305
+ async listSessionsByIds(
306
+ sessionIds: ReadonlySet<string>,
307
+ options?: { limit?: number; excludeCwd?: string },
308
+ ): Promise<ResumableTranscriptSessionListing> {
309
+ if (sessionIds.size === 0) return { sessions: [], total: 0 };
310
+
311
+ const excludeDir = await this.resolveProjectDir(options?.excludeCwd);
312
+ // Same zero-byte drop as `listSessions` — see the rationale there.
313
+ const entries = (await this.listEntriesByIds(sessionIds, excludeDir)).filter(e => e.size > 0);
314
+ if (entries.length === 0) return { sessions: [], total: 0 };
256
315
 
257
- // limit <= 0 means "no ceiling" — summarize every candidate.
258
- if (limit <= 0) {
259
- pushSummaries(await Promise.allSettled(candidates.map(e => this.resolveSummary(e))));
260
- return { sessions: sessions, total: candidates.length };
261
- }
316
+ const limit = options?.limit ?? defaultListLimit;
317
+ const { sessions, exhausted } = await this.collectSessions(entries, limit, async entry => {
318
+ const summary = await this.resolveSummary(entry);
319
+ if (summary == null || !hasSummaryContent(summary)) return undefined;
262
320
 
263
- const ceiling = Math.min(candidates.length, limit + listScanSlack);
264
- let cursor = 0;
265
- while (sessions.length < limit && cursor < ceiling) {
266
- // Clamped to the remaining ceiling budget too — a run of junk entries must not let a
267
- // need-sized batch read past `limit + listScanSlack`.
268
- const batch = candidates.slice(cursor, cursor + Math.min(limit - sessions.length, ceiling - cursor));
269
- cursor += batch.length;
270
- pushSummaries(await Promise.allSettled(batch.map(e => this.resolveSummary(e))));
271
- }
321
+ const cwd = await this.resolveTranscriptCwd(entry);
322
+ return cwd != null ? { ...summary, cwd: cwd } : undefined;
323
+ });
272
324
 
273
- return { sessions: sessions, total: candidates.length };
325
+ // Exhausted scans prove the exact count — see `listSessions`.
326
+ return { sessions: sessions, total: exhausted ? sessions.length : entries.length };
274
327
  }
275
328
 
276
329
  /** Discovers every transcript in `dir`, newest first. The directory also holds one sibling
@@ -280,14 +333,27 @@ export class ClaudeCodeTranscriptReader {
280
333
  const cached = this._listings.get(dir);
281
334
  if (cached != null && Date.now() - cached.resolvedAt < listingCacheTtlMs) return cached.entries;
282
335
 
336
+ const gen = this._listingGen;
283
337
  let names: string[];
284
338
  try {
285
- const dirents = await readdir(dir, { withFileTypes: true });
286
- names = dirents.filter(d => d.isFile() && d.name.endsWith('.jsonl')).map(d => d.name);
339
+ names = await this.listTranscriptNames(dir);
287
340
  } catch {
288
341
  return [];
289
342
  }
290
343
 
344
+ const entries = (await this.statEntries(dir, names)).sort((a, b) => b.lastActivityMs - a.lastActivityMs);
345
+
346
+ if (gen === this._listingGen) {
347
+ this._listings.set(dir, { entries: entries, resolvedAt: Date.now() });
348
+ }
349
+
350
+ return entries;
351
+ }
352
+
353
+ /** Stats `names` in `dir`, dropping the ones that failed — a transcript can vanish (or be
354
+ * archived) between the readdir and the stat. Unsorted; callers order as they need.
355
+ * Protected (like {@link readSummary}) so tests can interleave against the listing flow. */
356
+ protected async statEntries(dir: string, names: string[]): Promise<TranscriptSessionEntry[]> {
291
357
  const settled = await Promise.allSettled(
292
358
  names.map(async (name): Promise<TranscriptSessionEntry> => {
293
359
  const path = join(dir, name);
@@ -301,19 +367,145 @@ export class ClaudeCodeTranscriptReader {
301
367
  }),
302
368
  );
303
369
 
304
- const entries = settled
305
- .filter(r => r.status === 'fulfilled')
306
- .map(r => r.value)
307
- .sort((a, b) => b.lastActivityMs - a.lastActivityMs);
370
+ return settled.filter(r => r.status === 'fulfilled').map(r => r.value);
371
+ }
308
372
 
309
- this._listings.set(dir, { entries: entries, resolvedAt: Date.now() });
310
- return entries;
373
+ /** The `.jsonl` file names in `dir`, cached for {@link listingCacheTtlMs}. Failures propagate
374
+ * uncached: an unreadable directory means different things to different callers, and the next
375
+ * call must be free to get a real answer. */
376
+ private async listTranscriptNames(dir: string): Promise<string[]> {
377
+ const cached = this._names.get(dir);
378
+ if (cached != null && Date.now() - cached.resolvedAt < listingCacheTtlMs) return cached.names;
379
+
380
+ const gen = this._listingGen;
381
+ const dirents = await readdir(dir, { withFileTypes: true });
382
+ const names = dirents.filter(d => d.isFile() && d.name.endsWith('.jsonl')).map(d => d.name);
383
+ if (gen === this._listingGen) {
384
+ this._names.set(dir, { names: names, resolvedAt: Date.now() });
385
+ }
386
+
387
+ return names;
388
+ }
389
+
390
+ /** Every project directory under the projects root, cached for {@link listingCacheTtlMs} — a
391
+ * by-id recovery sweeps all of them, once per queried worktree. */
392
+ private async listProjectDirs(): Promise<string[]> {
393
+ const cached = this._projectDirs;
394
+ if (cached != null && Date.now() - cached.resolvedAt < listingCacheTtlMs) return cached.dirs;
395
+
396
+ const gen = this._listingGen;
397
+ const root = this.getProjectsRoot();
398
+ const dirents = await readdir(root, { withFileTypes: true });
399
+ const dirs = dirents.filter(d => d.isDirectory()).map(d => join(root, d.name));
400
+ if (gen === this._listingGen) {
401
+ this._projectDirs = { dirs: dirs, resolvedAt: Date.now() };
402
+ }
403
+
404
+ return dirs;
405
+ }
406
+
407
+ /** Scans project directory names once, then stats only requested transcript ids. */
408
+ private async listEntriesByIds(
409
+ sessionIds: ReadonlySet<string>,
410
+ excludeDir: string | undefined,
411
+ ): Promise<TranscriptSessionEntry[]> {
412
+ let idsToFind = sessionIds;
413
+ if (excludeDir != null) {
414
+ try {
415
+ const names = await this.listTranscriptNames(excludeDir);
416
+ const idsInExcludedDir = new Set(names.map(name => basename(name, '.jsonl')));
417
+ idsToFind = new Set([...sessionIds].filter(id => !idsInExcludedDir.has(id)));
418
+ if (idsToFind.size === 0) return [];
419
+ } catch {
420
+ // If the exact directory can't be inspected, don't risk returning a duplicate from a
421
+ // stale transcript copy elsewhere. The ordinary listing still owns the exact path.
422
+ return [];
423
+ }
424
+ }
425
+
426
+ let dirs: string[];
427
+ try {
428
+ dirs = await this.listProjectDirs();
429
+ } catch (ex) {
430
+ if (!isMissingEntry(ex)) throw ex;
431
+
432
+ return [];
433
+ }
434
+
435
+ const settledDirs = await Promise.allSettled(
436
+ dirs.map(async dir => {
437
+ if (dir === excludeDir) return [];
438
+
439
+ const names = (await this.listTranscriptNames(dir)).filter(name =>
440
+ idsToFind.has(basename(name, '.jsonl')),
441
+ );
442
+ return this.statEntries(dir, names);
443
+ }),
444
+ );
445
+
446
+ const bySessionId = new Map<string, TranscriptSessionEntry>();
447
+ for (const settledDir of settledDirs) {
448
+ if (settledDir.status !== 'fulfilled') continue;
449
+
450
+ for (const entry of settledDir.value) {
451
+ const existing = bySessionId.get(entry.sessionId);
452
+ if (existing == null || entry.lastActivityMs > existing.lastActivityMs) {
453
+ bySessionId.set(entry.sessionId, entry);
454
+ }
455
+ }
456
+ }
457
+
458
+ return [...bySessionId.values()].sort((a, b) => b.lastActivityMs - a.lastActivityMs);
459
+ }
460
+
461
+ /** `exhausted` reports whether every candidate was scanned — an exhausted scan has proven the
462
+ * exact valid count, so callers can report it instead of an upper bound that would keep a
463
+ * "Show More" affordance alive with nothing left to show. */
464
+ private async collectSessions<T>(
465
+ candidates: readonly TranscriptSessionEntry[],
466
+ limit: number,
467
+ resolve: (entry: TranscriptSessionEntry) => Promise<T | undefined>,
468
+ ): Promise<{ sessions: T[]; exhausted: boolean }> {
469
+ const sessions: T[] = [];
470
+ const pushResolved = (settled: PromiseSettledResult<T | undefined>[]): void => {
471
+ for (const result of settled) {
472
+ if (result.status !== 'fulfilled' || result.value == null) continue;
473
+
474
+ sessions.push(result.value);
475
+ }
476
+ };
477
+
478
+ // limit <= 0 means "no ceiling" — resolve every candidate.
479
+ if (limit <= 0) {
480
+ pushResolved(await Promise.allSettled(candidates.map(resolve)));
481
+ return { sessions: sessions, exhausted: true };
482
+ }
483
+
484
+ // Scans until `limit` results or the candidates run out — a fixed scan ceiling would let a run
485
+ // of junk transcripts hide every older real session behind it (and the picker then claims there
486
+ // are none at all). The cost stays bounded: callers pre-drop zero-byte candidates read-free, and
487
+ // a junk summary is windowed, tiny, and LRU-cached, so the sweep is paid in syscalls, once.
488
+ let cursor = 0;
489
+ while (sessions.length < limit && cursor < candidates.length) {
490
+ // Batched by remaining need so a hit-rich head stops the scan early instead of resolving
491
+ // every candidate up front.
492
+ const batch = candidates.slice(cursor, cursor + (limit - sessions.length));
493
+ cursor += batch.length;
494
+ pushResolved(await Promise.allSettled(batch.map(resolve)));
495
+ }
496
+
497
+ return { sessions: sessions, exhausted: cursor >= candidates.length };
311
498
  }
312
499
 
313
500
  /** Summarizes one transcript, reusing the cached result while its mtime and size are unchanged. */
314
501
  private async resolveSummary(entry: TranscriptSessionEntry): Promise<TranscriptSessionSummary | undefined> {
315
502
  const cached = this._summaries.get(entry.sessionId);
316
- if (cached != null && cached.mtimeMs === entry.lastActivityMs && cached.size === entry.size) {
503
+ if (
504
+ cached != null &&
505
+ cached.path === entry.path &&
506
+ cached.mtimeMs === entry.lastActivityMs &&
507
+ cached.size === entry.size
508
+ ) {
317
509
  // Refresh recency for the LRU.
318
510
  this._summaries.delete(entry.sessionId);
319
511
  this._summaries.set(entry.sessionId, cached);
@@ -328,6 +520,7 @@ export class ClaudeCodeTranscriptReader {
328
520
  }
329
521
 
330
522
  this._summaries.set(entry.sessionId, {
523
+ path: entry.path,
331
524
  mtimeMs: entry.lastActivityMs,
332
525
  size: entry.size,
333
526
  summary: summary,
@@ -341,6 +534,68 @@ export class ClaudeCodeTranscriptReader {
341
534
  return summary;
342
535
  }
343
536
 
537
+ /** Recovers the project path represented by the transcript's containing directory. A resolved
538
+ * answer is cached: it's a line-scan of the whole transcript, and the directory holding a given
539
+ * path never changes. A miss isn't cached — a `cwd` line can still land on append. */
540
+ private async resolveTranscriptCwd(entry: TranscriptSessionEntry): Promise<string | undefined> {
541
+ const cached = this._transcriptCwds.get(entry.path);
542
+ if (cached != null) return cached;
543
+
544
+ const projectDir = dirname(entry.path);
545
+ const projectDirName = basename(projectDir);
546
+ const matchesProjectDir = (cwd: string): boolean =>
547
+ encodeProjectDirName(cwd).toLowerCase() === projectDirName.toLowerCase();
548
+
549
+ try {
550
+ const index = JSON.parse(await readFile(join(projectDir, 'sessions-index.json'), 'utf8')) as {
551
+ entries?: { sessionId?: unknown; projectPath?: unknown }[];
552
+ };
553
+ if (Array.isArray(index.entries)) {
554
+ const indexed = index.entries.find(item => item?.sessionId === entry.sessionId);
555
+ if (typeof indexed?.projectPath === 'string' && matchesProjectDir(indexed.projectPath)) {
556
+ this.rememberTranscriptCwd(entry.path, indexed.projectPath);
557
+ return indexed.projectPath;
558
+ }
559
+ }
560
+ } catch {
561
+ // The index is optional and can lag or be malformed. The transcript is authoritative below.
562
+ }
563
+
564
+ const rl = createInterface({ input: createReadStream(entry.path, { encoding: 'utf8' }), crlfDelay: Infinity });
565
+ try {
566
+ for await (const line of rl) {
567
+ if (!line.includes('"cwd"')) continue;
568
+
569
+ let parsed: { cwd?: unknown; sessionId?: unknown };
570
+ try {
571
+ parsed = JSON.parse(line) as { cwd?: unknown; sessionId?: unknown };
572
+ } catch {
573
+ continue;
574
+ }
575
+ if (parsed.sessionId != null && parsed.sessionId !== entry.sessionId) continue;
576
+
577
+ if (typeof parsed.cwd === 'string' && matchesProjectDir(parsed.cwd)) {
578
+ this.rememberTranscriptCwd(entry.path, parsed.cwd);
579
+ return parsed.cwd;
580
+ }
581
+ }
582
+ } finally {
583
+ rl.close();
584
+ }
585
+
586
+ return undefined;
587
+ }
588
+
589
+ private rememberTranscriptCwd(path: string, cwd: string): void {
590
+ this._transcriptCwds.set(path, cwd);
591
+ if (this._transcriptCwds.size > summaryCacheLimit) {
592
+ const oldest = this._transcriptCwds.keys().next();
593
+ if (!oldest.done) {
594
+ this._transcriptCwds.delete(oldest.value);
595
+ }
596
+ }
597
+ }
598
+
344
599
  /**
345
600
  * Reads a summary from the first and last {@link summaryWindowSize} bytes of the transcript.
346
601
  *