claude-code-session-manager 0.104.1 → 0.104.2

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 (149) hide show
  1. package/dist/assets/AgentLibrary-QPw9gC_o.js +2 -0
  2. package/dist/assets/Button-8RAbgh1d.js +1 -0
  3. package/dist/assets/DataModel-BEkuOZuS.js +1 -0
  4. package/dist/assets/{History-BP6KKda_.js → History-BLrNSazM.js} +2 -2
  5. package/dist/assets/Hooks-C8P6wQq1.js +3 -0
  6. package/dist/assets/{Library-BprXNJCG.js → Library-CWFvBZw-.js} +5 -5
  7. package/dist/assets/ListDetail-B1jVMgzN.js +1 -0
  8. package/dist/assets/{MarkdownEditor-1KlkRAqT.js → MarkdownEditor-JrnUbWjW.js} +1 -1
  9. package/dist/assets/{McpServers-FUDN7EnU.js → McpServers-BGqTxDBm.js} +2 -2
  10. package/dist/assets/{Memory-DryJweXu.js → Memory-h8bhRNyI.js} +6 -6
  11. package/dist/assets/Panel-srf0FT0m.js +1 -0
  12. package/dist/assets/Permissions-BA4oWydI.js +3 -0
  13. package/dist/assets/Plugins-DTuTQqUL.js +2 -0
  14. package/dist/assets/{ProvenanceBadge-6Hj42klQ.js → ProvenanceBadge-BW18EquH.js} +1 -1
  15. package/dist/assets/{SaveBar-xSfjUPiu.js → SaveBar-Cc6UukFa.js} +1 -1
  16. package/dist/assets/{Scheduler-Beh3Vj-Z.js → Scheduler-BctPlNL1.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-9R1ANwns.js → ScopeSwitcher-DV1OnSuc.js} +1 -1
  18. package/dist/assets/Settings-Bpyt1RGa.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CYLTGabR.js → SkillReferenceGraph-B28AYo12.js} +1 -1
  20. package/dist/assets/Skills-zXLI5OB_.js +3 -0
  21. package/dist/assets/SystemPrompt-DKoN0UAV.js +1 -0
  22. package/dist/assets/TagLibrary-BkDOa8IM.js +1 -0
  23. package/dist/assets/{TiptapBody-l-BMtrPd.js → TiptapBody-CIY1f6mI.js} +1 -1
  24. package/dist/assets/{Toggle-Bj2Ia7AE.js → Toggle-C139wuAK.js} +1 -1
  25. package/dist/assets/ViewTabs-CUEfsMOe.js +1 -0
  26. package/dist/assets/claude-settings-schema-COigsajx.js +3 -0
  27. package/dist/assets/index-DrvxIbmR.js +3073 -0
  28. package/dist/assets/monaco-editor-BsIlgG7c.js +917 -0
  29. package/dist/assets/monaco-editor-Cu5D__1H.css +1 -0
  30. package/dist/assets/settingsSchema-CAFckLz6.js +1 -0
  31. package/dist/assets/useAgentsDirWatch-XhQEVlaK.js +1 -0
  32. package/dist/assets/useConfigDirWatch-QKxfZx9X.js +1 -0
  33. package/dist/index.html +3 -3
  34. package/package.json +3 -3
  35. package/src/main/build-info.json +4 -4
  36. package/src/main/lib/activeSessions.cjs +263 -71
  37. package/src/main/lib/prdMigration.cjs +64 -1
  38. package/src/main/lib/queueHistory.cjs +97 -26
  39. package/src/main/pty.cjs +50 -1
  40. package/src/main/usage.cjs +4 -1
  41. package/dist/assets/DataModel-BHcPa2nA.js +0 -1
  42. package/dist/assets/Hooks-CnJnddP6.js +0 -3
  43. package/dist/assets/Permissions-Df-LWvdk.js +0 -3
  44. package/dist/assets/Plugins-CIUOw9B2.js +0 -2
  45. package/dist/assets/Settings-gkp0WfEm.js +0 -3
  46. package/dist/assets/Skills-Dw5Am-f-.js +0 -3
  47. package/dist/assets/SystemPrompt-B3GwTLqh.js +0 -1
  48. package/dist/assets/TagLibrary-Dj46gOG2.js +0 -1
  49. package/dist/assets/ViewTabs-BQ_HbQFq.js +0 -1
  50. package/dist/assets/abap-DLDM7-KI.js +0 -1
  51. package/dist/assets/apex-DNDY2TF8.js +0 -1
  52. package/dist/assets/azcli-Y6nb8tq_.js +0 -1
  53. package/dist/assets/bat-BwHxbl9M.js +0 -1
  54. package/dist/assets/bicep-CFznDFnq.js +0 -2
  55. package/dist/assets/cameligo-Bf6VGUru.js +0 -1
  56. package/dist/assets/clojure-Dnu-v4kV.js +0 -1
  57. package/dist/assets/coffee-Bd8akH9Z.js +0 -1
  58. package/dist/assets/cpp-BbWJElDN.js +0 -1
  59. package/dist/assets/csharp-Co3qMtFm.js +0 -1
  60. package/dist/assets/csp-D-4FJmMZ.js +0 -1
  61. package/dist/assets/css-DdJfP1eB.js +0 -3
  62. package/dist/assets/css.worker-B4z49cGk.js +0 -93
  63. package/dist/assets/cssMode-DfqZGMQs.js +0 -1
  64. package/dist/assets/cypher-cTPe9QuQ.js +0 -1
  65. package/dist/assets/dart-BOtBlQCF.js +0 -1
  66. package/dist/assets/dockerfile-BG73LgW2.js +0 -1
  67. package/dist/assets/ecl-BEgZUVRK.js +0 -1
  68. package/dist/assets/elixir-BkW5O-1t.js +0 -1
  69. package/dist/assets/flow9-BeJ5waoc.js +0 -1
  70. package/dist/assets/freemarker2-XTPYh37h.js +0 -3
  71. package/dist/assets/fsharp-PahG7c26.js +0 -1
  72. package/dist/assets/go-acbASCJo.js +0 -1
  73. package/dist/assets/graphql-BxJiqAUM.js +0 -1
  74. package/dist/assets/handlebars-DKUF5VyH.js +0 -1
  75. package/dist/assets/hcl-DtV1sZF8.js +0 -1
  76. package/dist/assets/html-uqoqsIeI.js +0 -1
  77. package/dist/assets/html.worker-DtiGdgqp.js +0 -470
  78. package/dist/assets/htmlMode-aMTQs1su.js +0 -1
  79. package/dist/assets/index-DbR91Z5j.js +0 -3076
  80. package/dist/assets/ini-Kd9XrMLS.js +0 -1
  81. package/dist/assets/java-CXBNlu9o.js +0 -1
  82. package/dist/assets/javascript-BVxRZMds.js +0 -1
  83. package/dist/assets/jsonMode-D04xP2s5.js +0 -7
  84. package/dist/assets/julia-cl7-CwDS.js +0 -1
  85. package/dist/assets/kotlin-s7OhZKlX.js +0 -1
  86. package/dist/assets/less-9HpZscsL.js +0 -2
  87. package/dist/assets/lexon-OrD6JF1K.js +0 -1
  88. package/dist/assets/liquid-BkQHTH2P.js +0 -1
  89. package/dist/assets/lspLanguageFeatures-By9uLznH.js +0 -4
  90. package/dist/assets/lua-Cyyb5UIc.js +0 -1
  91. package/dist/assets/m3-B8OfTtLu.js +0 -1
  92. package/dist/assets/markdown-BFxVWTOG.js +0 -1
  93. package/dist/assets/mdx-Du1IlbjV.js +0 -1
  94. package/dist/assets/mips-CiqrrVzr.js +0 -1
  95. package/dist/assets/monaco-editor-BTnBOi8r.css +0 -1
  96. package/dist/assets/monaco-editor-BW5C4Iv1.js +0 -908
  97. package/dist/assets/msdax-DmeGPVcC.js +0 -1
  98. package/dist/assets/mysql-C_tMU-Nz.js +0 -1
  99. package/dist/assets/objective-c-BDtDVThU.js +0 -1
  100. package/dist/assets/pascal-vHIfCaH5.js +0 -1
  101. package/dist/assets/pascaligo-DtZ0uQbO.js +0 -1
  102. package/dist/assets/perl-Ub6l9XKa.js +0 -1
  103. package/dist/assets/pgsql-BlNEE0v7.js +0 -1
  104. package/dist/assets/php-BBUBE1dy.js +0 -1
  105. package/dist/assets/pla-DSh2-awV.js +0 -1
  106. package/dist/assets/postiats-CocnycG-.js +0 -1
  107. package/dist/assets/powerquery-tScXyioY.js +0 -1
  108. package/dist/assets/powershell-COWaemsV.js +0 -1
  109. package/dist/assets/protobuf-Brw8urJB.js +0 -2
  110. package/dist/assets/pug-8SOpv6rk.js +0 -1
  111. package/dist/assets/python-DSlImqXd.js +0 -1
  112. package/dist/assets/qsharp-Bw9ernYp.js +0 -1
  113. package/dist/assets/r-j7ic8hl3.js +0 -1
  114. package/dist/assets/razor-BmUVyvSK.js +0 -1
  115. package/dist/assets/redis-Bu5POkcn.js +0 -1
  116. package/dist/assets/redshift-Bs9aos_-.js +0 -1
  117. package/dist/assets/restructuredtext-CqXO7rUv.js +0 -1
  118. package/dist/assets/ruby-zBfavPgS.js +0 -1
  119. package/dist/assets/rust-BzKRNQWT.js +0 -1
  120. package/dist/assets/sb-BBc9UKZt.js +0 -1
  121. package/dist/assets/scala-D9hQfWCl.js +0 -1
  122. package/dist/assets/scheme-BPhDTwHR.js +0 -1
  123. package/dist/assets/scss-CBJaRo0y.js +0 -3
  124. package/dist/assets/settingsSchema-cR833AB2.js +0 -1
  125. package/dist/assets/shell-DiJ1NA_G.js +0 -1
  126. package/dist/assets/solidity-Db0IVjzk.js +0 -1
  127. package/dist/assets/sophia-CnS9iZB_.js +0 -1
  128. package/dist/assets/sparql-CJmd_6j2.js +0 -1
  129. package/dist/assets/sql-ClhHkBeG.js +0 -1
  130. package/dist/assets/st-CHwy0fLd.js +0 -1
  131. package/dist/assets/swift-Bqt4WxQ4.js +0 -3
  132. package/dist/assets/systemverilog-Bs9z6M-B.js +0 -1
  133. package/dist/assets/tcl-Dm6ycUr_.js +0 -1
  134. package/dist/assets/ts.worker-59MjiAqk.js +0 -67731
  135. package/dist/assets/tsMode-Btj0TTH7.js +0 -11
  136. package/dist/assets/twig-Csy3S7wG.js +0 -1
  137. package/dist/assets/typescript-Bzelq9vO.js +0 -1
  138. package/dist/assets/typespec-Btyra-wh.js +0 -1
  139. package/dist/assets/vb-Db0cS2oM.js +0 -1
  140. package/dist/assets/wgsl-BTesnYfV.js +0 -298
  141. package/dist/assets/xml-Whd9EaSd.js +0 -1
  142. package/dist/assets/yaml-QYf0-IN8.js +0 -1
  143. package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +0 -112
  144. package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
  145. package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +0 -106
  146. package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
  147. package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +0 -107
  148. package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
  149. package/src/preload/__tests__/preload-surface.test.cjs +0 -68
@@ -9,6 +9,7 @@
9
9
  // which PRD 356-retire purges along with its capture hook.
10
10
 
11
11
  const fs = require('node:fs');
12
+ const fsp = require('node:fs/promises');
12
13
  const os = require('node:os');
13
14
  const path = require('node:path');
14
15
  const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('./gitWorktree.cjs');
@@ -43,13 +44,14 @@ const TAIL_BYTES = 64 * 1024;
43
44
  // Safety cap on distinct cwds to bound result set size. O(1) extra space.
44
45
  const MAX_CWDS = 50;
45
46
 
46
- // activeProjectCwds' scan is a readdirSync of every dir under ~/.claude
47
- // /projects (2209 dirs / 8.8 GB observed) plus a statSync per transcript —
48
- // ~270 ms warm. reconcile() calls into it (directly or via allProjectCwds)
49
- // several times per pass, so cache the resolved cwd list keyed on the
50
- // resolved argument TUPLE (projectsDir, maxAgeMin, maxCwds) — never on
51
- // `!opts`, since allProjectCwds always constructs an options object and that
52
- // guard would cache nothing on the hottest path.
47
+ // The disk scan (readdirSync of every dir under ~/.claude/projects — 2209
48
+ // dirs / 8.8 GB observed — plus a statSync per transcript, ~270 ms warm) is
49
+ // the expensive part. allProjectCwds() and activeProjectCwds() used to each
50
+ // pay their own full scan under separate (projectsDir, maxAgeMin, maxCwds)
51
+ // cache keys even though they read the same on-disk facts. Now the scan runs
52
+ // ONCE per projectsDir and is cached as a flat per-project record
53
+ // {rawCwd, newestMtimeMs}; every view (any maxAgeMin/maxCwds combination)
54
+ // filters/sorts that one record in memory instead of touching disk again.
53
55
  //
54
56
  // Invalidation: projectsDir's own mtime (bumps when a project DIRECTORY is
55
57
  // added/removed — NOT when an existing transcript gains a line, which is
@@ -58,13 +60,20 @@ const MAX_CWDS = 50;
58
60
  // scheduler/prdParser.cjs's dirCache and queueHistory.cjs's historyCacheKey.
59
61
  // TTL sits above the 60 s dispatch-loop interval (scheduler POLL_INTERVAL_MS) so
60
62
  // one pass never re-scans cold. Cached arrays are frozen — shared, never mutated.
63
+ // Stale-while-revalidate: once a projectsDir has been scanned at least once,
64
+ // an expired cache entry is served to the (synchronous) caller as-is, and at
65
+ // most one background fs.promises rescan is kicked off to refresh it. Only
66
+ // the very first scan of a given projectsDir pays the synchronous cost —
67
+ // every expiry after that is a cache read plus a fire-and-forget refresh.
61
68
  const CACHE_TTL_MS = 120_000;
69
+ // Bounds how many project dirs the async rescan reads concurrently.
70
+ const RESCAN_CONCURRENCY = 32;
62
71
  const EMPTY_CWDS = Object.freeze([]);
63
- const cwdScanCache = new Map(); // key -> { dirMtimeMs, cachedAt, result }
72
+ const rawScanCache = new Map(); // projectsDir -> { dirMtimeMs, cachedAt, records, refreshPromise }
64
73
 
65
74
  /** Forces the next activeProjectCwds/allProjectCwds call to rescan. */
66
75
  function bustProjectCwdCache() {
67
- cwdScanCache.clear();
76
+ rawScanCache.clear();
68
77
  }
69
78
 
70
79
  // The ops-root folder name. A transcript's `cwd` can point INSIDE it whenever
@@ -120,42 +129,231 @@ function readTailLines(filePath, maxBytes) {
120
129
  }
121
130
 
122
131
  /**
123
- * activeProjectCwds(maxAgeMin = 90, opts?) → string[]
124
- *
125
- * Returns distinct, on-disk project cwds that have an open/active session
126
- * within the last maxAgeMin minutes.
127
- *
128
- * Sole detection path: scan ~/.claude/projects/*​/ for transcript *.jsonl
129
- * files modified within maxAgeMin, read `cwd` from the last parseable line
130
- * of the newest transcript per project dir.
131
- * Complexity: O(P × L) over P project dirs, each bounded-tail read (64 KB).
132
+ * scanOneProjectDirSync(projDir) → {rawCwd, newestMtimeMs} | null
133
+ * Newest *.jsonl's mtime plus its last parseable `cwd`, for one project dir.
134
+ */
135
+ function scanOneProjectDirSync(projDir) {
136
+ let entries;
137
+ try {
138
+ entries = fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl'));
139
+ } catch { return null; }
140
+
141
+ let newestPath = null;
142
+ let newestMtimeMs = 0;
143
+ for (const tf of entries) {
144
+ const fp = path.join(projDir, tf);
145
+ try {
146
+ const st = fs.statSync(fp);
147
+ if (st.mtimeMs > newestMtimeMs) {
148
+ newestMtimeMs = st.mtimeMs;
149
+ newestPath = fp;
150
+ }
151
+ } catch { continue; }
152
+ }
153
+ if (!newestPath) return null;
154
+
155
+ const tlines = readTailLines(newestPath, TAIL_BYTES);
156
+ let rawCwd = null;
157
+ for (let i = tlines.length - 1; i >= 0; i--) {
158
+ let row;
159
+ try { row = JSON.parse(tlines[i]); } catch { continue; }
160
+ if (row.cwd) { rawCwd = row.cwd; break; }
161
+ }
162
+ return rawCwd ? { rawCwd, newestMtimeMs } : null;
163
+ }
164
+
165
+ /**
166
+ * scanProjectsDir(projectsDir) → {rawCwd, newestMtimeMs}[] | null
132
167
  *
133
- * opts (for testing):
134
- * projectsDir — override the default ~/.claude/projects path
135
- * tmpDropRoots — override the tmp guard's drop roots (default TMP_DROP_ROOTS)
168
+ * The first scan of a given projectsDir is synchronous (readdirSync of
169
+ * projectsDir, then per project dir as above) and its result is cached.
170
+ * A fresh cache entry (same dirMtimeMs, within TTL) is returned as-is.
171
+ * Once the dir's own mtime changes (a project dir was added/removed), the
172
+ * next call rescans synchronously and returns the new result — a stale
173
+ * record would otherwise hide a just-registered project from this caller.
174
+ * Only when dirMtimeMs is unchanged and the TTL has expired is the stale
175
+ * record returned as-is, with startAsyncRefresh kicking off at most one
176
+ * background fs.promises rescan to refresh it for the next caller. Returns
177
+ * null when projectsDir is missing/unreadable and no record has ever been
178
+ * cached for it.
136
179
  */
137
- function activeProjectCwds(maxAgeMin = 90, {
138
- projectsDir = path.join(HOME, '.claude', 'projects'),
139
- maxCwds = MAX_CWDS,
140
- tmpDropRoots = TMP_DROP_ROOTS,
141
- } = {}) {
142
- const cacheKey = [projectsDir, maxAgeMin, maxCwds].join('|');
180
+ function scanProjectsDir(projectsDir) {
143
181
  let dirMtimeMs;
144
182
  try {
145
183
  dirMtimeMs = fs.statSync(projectsDir).mtimeMs;
146
184
  } catch {
147
- // Missing/unreadable dir: nothing to scan, and any prior cache entry for
148
- // this key must not survive to be handed back once the dir later
149
- // appears — drop it rather than caching this empty result.
150
- cwdScanCache.delete(cacheKey);
151
- return EMPTY_CWDS;
185
+ // Missing/unreadable dir: any prior cache entry must not survive to be
186
+ // handed back once the dir later appears — drop it rather than caching
187
+ // this empty result.
188
+ rawScanCache.delete(projectsDir);
189
+ return null;
152
190
  }
153
- const cached = cwdScanCache.get(cacheKey);
191
+ const cached = rawScanCache.get(projectsDir);
154
192
  const now0 = Date.now();
155
193
  if (cached && cached.dirMtimeMs === dirMtimeMs && now0 - cached.cachedAt < CACHE_TTL_MS) {
156
- return cached.result;
194
+ return cached.records;
195
+ }
196
+
197
+ // Same mtime, TTL expired: a prior successful scan exists and the dir
198
+ // itself hasn't changed, so serve it stale and refresh in the background
199
+ // rather than blocking this synchronous caller.
200
+ if (cached && cached.dirMtimeMs === dirMtimeMs) {
201
+ startAsyncRefresh(projectsDir);
202
+ return cached.records;
203
+ }
204
+
205
+ // Either the first-ever scan of this projectsDir, or the dir's mtime
206
+ // changed (a project was added/removed) — must be synchronous, since a
207
+ // stale record would hide the change from this caller.
208
+ let slugs;
209
+ try { slugs = fs.readdirSync(projectsDir); } catch { rawScanCache.delete(projectsDir); return null; }
210
+
211
+ const records = [];
212
+ for (const slug of slugs) {
213
+ const rec = scanOneProjectDirSync(path.join(projectsDir, slug));
214
+ if (rec) records.push(rec);
215
+ }
216
+
217
+ Object.freeze(records);
218
+ rawScanCache.set(projectsDir, { dirMtimeMs, cachedAt: now0, records, refreshPromise: null });
219
+ return records;
220
+ }
221
+
222
+ /**
223
+ * readTailLinesAsync(filePath, maxBytes) → Promise<string[]>
224
+ * fs.promises counterpart of readTailLines, used only by the async rescan.
225
+ */
226
+ async function readTailLinesAsync(filePath, maxBytes) {
227
+ let buf;
228
+ let fh;
229
+ try {
230
+ const stat = await fsp.stat(filePath);
231
+ if (stat.size === 0) return [];
232
+ const readSize = Math.min(maxBytes, stat.size);
233
+ fh = await fsp.open(filePath, 'r');
234
+ buf = Buffer.alloc(readSize);
235
+ await fh.read(buf, 0, readSize, stat.size - readSize);
236
+ } catch {
237
+ return [];
238
+ } finally {
239
+ if (fh) await fh.close().catch(() => {});
240
+ }
241
+ return buf.toString('utf8').split('\n').filter((l) => l.trim());
242
+ }
243
+
244
+ /**
245
+ * scanOneProjectDirAsync(projDir) → Promise<{rawCwd, newestMtimeMs} | null>
246
+ * fs.promises counterpart of scanOneProjectDirSync, used only by the async rescan.
247
+ */
248
+ async function scanOneProjectDirAsync(projDir) {
249
+ let entries;
250
+ try {
251
+ entries = (await fsp.readdir(projDir)).filter((f) => f.endsWith('.jsonl'));
252
+ } catch { return null; }
253
+
254
+ let newestPath = null;
255
+ let newestMtimeMs = 0;
256
+ for (const tf of entries) {
257
+ const fp = path.join(projDir, tf);
258
+ try {
259
+ const st = await fsp.stat(fp);
260
+ if (st.mtimeMs > newestMtimeMs) {
261
+ newestMtimeMs = st.mtimeMs;
262
+ newestPath = fp;
263
+ }
264
+ } catch { continue; }
157
265
  }
266
+ if (!newestPath) return null;
158
267
 
268
+ const tlines = await readTailLinesAsync(newestPath, TAIL_BYTES);
269
+ let rawCwd = null;
270
+ for (let i = tlines.length - 1; i >= 0; i--) {
271
+ let row;
272
+ try { row = JSON.parse(tlines[i]); } catch { continue; }
273
+ if (row.cwd) { rawCwd = row.cwd; break; }
274
+ }
275
+ return rawCwd ? { rawCwd, newestMtimeMs } : null;
276
+ }
277
+
278
+ /**
279
+ * rescanProjectsDirAsync(projectsDir) → Promise<{dirMtimeMs, records}>
280
+ * The background counterpart of the first-scan branch in scanProjectsDir:
281
+ * same semantics, entirely fs.promises, with the per-project-dir reads
282
+ * bounded to RESCAN_CONCURRENCY in flight at once. A top-level failure
283
+ * (missing dir, unreadable) is left to throw/reject — startAsyncRefresh's
284
+ * caller catches it, logs once, and keeps the old cached record.
285
+ */
286
+ async function rescanProjectsDirAsync(projectsDir) {
287
+ const dirMtimeMs = (await fsp.stat(projectsDir)).mtimeMs;
288
+ const slugs = await fsp.readdir(projectsDir);
289
+
290
+ const records = [];
291
+ let nextIdx = 0;
292
+ async function worker() {
293
+ for (;;) {
294
+ const i = nextIdx++;
295
+ if (i >= slugs.length) return;
296
+ const rec = await scanOneProjectDirAsync(path.join(projectsDir, slugs[i]));
297
+ if (rec) records.push(rec);
298
+ }
299
+ }
300
+ const workerCount = Math.min(RESCAN_CONCURRENCY, slugs.length);
301
+ await Promise.all(Array.from({ length: workerCount }, worker));
302
+
303
+ Object.freeze(records);
304
+ return { dirMtimeMs, records };
305
+ }
306
+
307
+ /**
308
+ * startAsyncRefresh(projectsDir) → Promise<void>
309
+ * Kicks off at most one in-flight background rescan per projectsDir — later
310
+ * calls while one is in flight get back the same promise instead of starting
311
+ * another. On success the cache entry is swapped atomically; on failure the
312
+ * old record is kept and the error is logged once. Never throws/rejects
313
+ * into a synchronous caller — scanProjectsDir never awaits this.
314
+ */
315
+ function startAsyncRefresh(projectsDir) {
316
+ const entry = rawScanCache.get(projectsDir);
317
+ if (!entry) return Promise.resolve();
318
+ if (entry.refreshPromise) return entry.refreshPromise;
319
+
320
+ const promise = rescanProjectsDirAsync(projectsDir)
321
+ .then((result) => {
322
+ const current = rawScanCache.get(projectsDir);
323
+ if (!current) return; // busted mid-flight; nothing to swap into
324
+ rawScanCache.set(projectsDir, {
325
+ dirMtimeMs: result.dirMtimeMs, cachedAt: Date.now(), records: result.records, refreshPromise: null,
326
+ });
327
+ })
328
+ .catch((err) => {
329
+ console.error(`[activeSessions] async rescan of ${projectsDir} failed, keeping stale record: ${err.message}`);
330
+ const current = rawScanCache.get(projectsDir);
331
+ if (current) current.refreshPromise = null;
332
+ });
333
+
334
+ entry.refreshPromise = promise;
335
+ return promise;
336
+ }
337
+
338
+ /**
339
+ * __waitForPendingRefresh(projectsDir) → Promise<void>
340
+ * Test-only hook: awaits the in-flight async rescan for projectsDir, if any.
341
+ * Resolves immediately when no refresh is in flight.
342
+ */
343
+ function __waitForPendingRefresh(projectsDir) {
344
+ const entry = rawScanCache.get(projectsDir);
345
+ return entry && entry.refreshPromise ? entry.refreshPromise : Promise.resolve();
346
+ }
347
+
348
+ /**
349
+ * deriveCwds(records, maxAgeMin, maxCwds, tmpDropRoots) → string[]
350
+ *
351
+ * Filters/sorts a scanProjectsDir() record into the view activeProjectCwds/
352
+ * allProjectCwds promise: age cutoff, ops-root/worktree normalization,
353
+ * dedup, existence check, and the maxCwds cap — identical semantics to the
354
+ * old per-call scan, now performed in memory against the shared record.
355
+ */
356
+ function deriveCwds(records, maxAgeMin, maxCwds, tmpDropRoots) {
159
357
  // maxAgeMin === Infinity means "every project ever seen, no recency filter"
160
358
  // (allProjectCwds below). Date.now() - Infinity is -Infinity, which every
161
359
  // mtime clears — spelled out here because it reads like an accident.
@@ -199,46 +397,39 @@ function activeProjectCwds(maxAgeMin = 90, {
199
397
  result.push(cwd);
200
398
  }
201
399
 
202
- // Scan ~/.claude/projects/*/ transcript *.jsonl files.
203
- let slugs;
204
- try { slugs = fs.readdirSync(projectsDir); } catch { cwdScanCache.delete(cacheKey); return Object.freeze(result); }
205
-
206
- for (const slug of slugs) {
400
+ for (const { rawCwd, newestMtimeMs } of records) {
207
401
  if (result.length >= maxCwds) break;
208
- const projDir = path.join(projectsDir, slug);
209
- let entries;
210
- try {
211
- entries = fs.readdirSync(projDir).filter((f) => f.endsWith('.jsonl'));
212
- } catch { continue; }
213
-
214
- // Find the most recently modified transcript in this project dir.
215
- let newestPath = null;
216
- let newestMtimeMs = 0;
217
- for (const tf of entries) {
218
- const fp = path.join(projDir, tf);
219
- try {
220
- const st = fs.statSync(fp);
221
- if (st.mtimeMs > newestMtimeMs) {
222
- newestMtimeMs = st.mtimeMs;
223
- newestPath = fp;
224
- }
225
- } catch { continue; }
226
- }
227
-
228
- if (!newestPath || newestMtimeMs < cutoffMs) continue;
229
-
230
- // Read `cwd` from the last parseable line of the transcript.
231
- const tlines = readTailLines(newestPath, TAIL_BYTES);
232
- for (let i = tlines.length - 1; i >= 0; i--) {
233
- let row;
234
- try { row = JSON.parse(tlines[i]); } catch { continue; }
235
- if (row.cwd) { addCwd(row.cwd); break; }
236
- }
402
+ if (newestMtimeMs < cutoffMs) continue;
403
+ addCwd(rawCwd);
237
404
  }
238
405
 
239
- Object.freeze(result);
240
- cwdScanCache.set(cacheKey, { dirMtimeMs, cachedAt: now0, result });
241
- return result;
406
+ return Object.freeze(result);
407
+ }
408
+
409
+ /**
410
+ * activeProjectCwds(maxAgeMin = 90, opts?) → string[]
411
+ *
412
+ * Returns distinct, on-disk project cwds that have an open/active session
413
+ * within the last maxAgeMin minutes.
414
+ *
415
+ * Sole detection path: scan ~/.claude/projects/*​/ for transcript *.jsonl
416
+ * files modified within maxAgeMin, read `cwd` from the last parseable line
417
+ * of the newest transcript per project dir.
418
+ * Complexity: O(P × L) over P project dirs, each bounded-tail read (64 KB) —
419
+ * paid once per TTL window (see scanProjectsDir), shared across views.
420
+ *
421
+ * opts (for testing):
422
+ * projectsDir — override the default ~/.claude/projects path
423
+ * tmpDropRoots — override the tmp guard's drop roots (default TMP_DROP_ROOTS)
424
+ */
425
+ function activeProjectCwds(maxAgeMin = 90, {
426
+ projectsDir = path.join(HOME, '.claude', 'projects'),
427
+ maxCwds = MAX_CWDS,
428
+ tmpDropRoots = TMP_DROP_ROOTS,
429
+ } = {}) {
430
+ const records = scanProjectsDir(projectsDir);
431
+ if (!records) return EMPTY_CWDS;
432
+ return deriveCwds(records, maxAgeMin, maxCwds, tmpDropRoots);
242
433
  }
243
434
 
244
435
  /**
@@ -266,4 +457,5 @@ module.exports = {
266
457
  projectRootOf,
267
458
  worktreeMainRootOf,
268
459
  bustProjectCwdCache,
460
+ __waitForPendingRefresh,
269
461
  };
@@ -166,13 +166,55 @@ function slugForPrdFile(name) {
166
166
  return name.slice(0, -3);
167
167
  }
168
168
 
169
+ /**
170
+ * Per-cwd cache of the flat prds/ dir's last-swept state, keyed by cwd.
171
+ * {exists, mtimeMs, failed, skipped} — failed/skipped are the RESIDUAL
172
+ * (still-in-place) files from the last real sweep, so a cache hit can report
173
+ * them again without re-reading anything. `moved` is deliberately NOT cached:
174
+ * it is a count of actions taken during a pass, and a pass that does no work
175
+ * (cache hit) always reports moved: 0, never replays a stale move count.
176
+ *
177
+ * Perf win (PRD: flatPrdSweep phase, p50 1.3s/max 5.7s over up to 500 cwds
178
+ * per reconcile): a cwd whose flat dir is absent or whose mtime hasn't
179
+ * changed since the last successful sweep skips the readdir AND the
180
+ * liveSlugsForCwd() queue.json parse entirely — only a cheap stat runs.
181
+ */
182
+ const flatDirSweepCache = new Map();
183
+
184
+ /** Test-only: clear the per-cwd flat-dir sweep cache between test cases. */
185
+ function resetFlatDirSweepCacheForTests() {
186
+ flatDirSweepCache.clear();
187
+ }
188
+
169
189
  async function consolidateFlatPrds(cwd, opts = {}) {
170
190
  const flatDir = resolvePrdWriteDir(cwd);
171
191
  const archiveDir = path.join(path.dirname(flatDir), 'prds-archived');
192
+
193
+ let mtimeMs;
194
+ try {
195
+ mtimeMs = (await fsp.stat(flatDir)).mtimeMs;
196
+ } catch (e) {
197
+ if (e?.code !== 'ENOENT') throw e;
198
+ mtimeMs = null;
199
+ }
200
+
201
+ if (mtimeMs === null) {
202
+ flatDirSweepCache.set(cwd, { exists: false, mtimeMs: null, failed: [], skipped: [] });
203
+ return { moved: 0, failed: [], skipped: [] };
204
+ }
205
+
206
+ const cached = flatDirSweepCache.get(cwd);
207
+ if (cached && cached.exists && cached.mtimeMs === mtimeMs) {
208
+ // Unchanged since the last successful sweep — no readdir, no queue.json
209
+ // parse. Report the same residual (still-blocked/still-failed) files.
210
+ return { moved: 0, failed: cached.failed, skipped: cached.skipped };
211
+ }
212
+
172
213
  let entries;
173
214
  try {
174
215
  entries = await fsp.readdir(flatDir);
175
216
  } catch {
217
+ flatDirSweepCache.set(cwd, { exists: false, mtimeMs: null, failed: [], skipped: [] });
176
218
  return { moved: 0, failed: [], skipped: [] };
177
219
  }
178
220
 
@@ -188,6 +230,8 @@ async function consolidateFlatPrds(cwd, opts = {}) {
188
230
  if (!name.endsWith('.md') || name.startsWith('.')) continue;
189
231
  skipped.push({ file: name, reason: 'queue state unreadable — cannot prove no live job' });
190
232
  }
233
+ // Never cache a fail-closed read as settled — queue state may become
234
+ // readable on the very next pass and must be re-parsed, not replayed.
191
235
  return { moved: 0, failed, skipped };
192
236
  }
193
237
 
@@ -211,6 +255,19 @@ async function consolidateFlatPrds(cwd, opts = {}) {
211
255
  failed.push({ file: name, reason: e?.message ?? 'move failed' });
212
256
  }
213
257
  }
258
+
259
+ // Re-stat after the sweep: renames just mutated the dir's own mtime, so the
260
+ // cached value must reflect the post-sweep state, not the one we read in.
261
+ let postMtimeMs = null;
262
+ try {
263
+ postMtimeMs = (await fsp.stat(flatDir)).mtimeMs;
264
+ } catch { /* removed concurrently — leave uncached, next pass re-checks */ }
265
+ if (postMtimeMs !== null) {
266
+ flatDirSweepCache.set(cwd, { exists: true, mtimeMs: postMtimeMs, failed, skipped });
267
+ } else {
268
+ flatDirSweepCache.delete(cwd);
269
+ }
270
+
214
271
  return { moved, failed, skipped };
215
272
  }
216
273
 
@@ -275,4 +332,10 @@ async function legacyAdoptExistingPrds() {
275
332
  return { stamped, failed };
276
333
  }
277
334
 
278
- module.exports = { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds, LIVE_JOB_STATUSES };
335
+ module.exports = {
336
+ migratePrds,
337
+ consolidateFlatPrds,
338
+ legacyAdoptExistingPrds,
339
+ LIVE_JOB_STATUSES,
340
+ resetFlatDirSweepCacheForTests,
341
+ };
@@ -236,11 +236,96 @@ async function readHistoryFile(target, cap) {
236
236
  let historyCacheKey = null;
237
237
  let historyCacheMap = null;
238
238
 
239
+ // Per-file byte-offset cache backing historyTerminalBySlug's incremental
240
+ // read: path -> { ino, size, offset, map }. `map` is this file's own
241
+ // slug -> record result as of `offset` bytes; `offset` always sits on a
242
+ // line boundary (the byte right after the last consumed '\n') so a partial
243
+ // trailing line never gets counted as consumed.
244
+ const perFileCache = new Map();
245
+
246
+ /**
247
+ * needs_review_entry/needs_review_resolution lines (needsReviewLedger.cjs)
248
+ * share this file but aren't a job's terminal record — skip them, or a
249
+ * needs_review episode logged for a slug AFTER its real terminal row (e.g.
250
+ * it re-parked on a later run) would overwrite a true 'completed' with a
251
+ * kind-less, status-less row read as "not terminal".
252
+ */
253
+ function applyTerminalLines(text, map) {
254
+ if (!text) return;
255
+ for (const line of text.split('\n')) {
256
+ if (!line.trim()) continue;
257
+ try {
258
+ const j = JSON.parse(line);
259
+ if (j?.kind && j.kind !== 'terminal') continue;
260
+ if (j?.slug) map.set(j.slug, { status: j.status, finishedAt: j.finishedAt, landedCommit: j.landedCommit ?? null });
261
+ } catch {
262
+ // corrupt/partial line — ignore
263
+ }
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Returns this one file's slug -> record map, reading only the bytes
269
+ * appended since the last call when possible. Falls back to a full
270
+ * re-parse when the file is new to the cache, its inode changed, or its
271
+ * size went backwards (rotation/truncation).
272
+ */
273
+ async function readFileTerminalMap(target, stat) {
274
+ const cached = perFileCache.get(target);
275
+ const { ino, size } = stat;
276
+
277
+ if (cached && cached.ino === ino && size >= cached.size) {
278
+ if (size === cached.size) return cached.map;
279
+
280
+ const len = size - cached.offset;
281
+ const fh = await fsp.open(target, 'r');
282
+ let buf;
283
+ try {
284
+ buf = Buffer.alloc(len);
285
+ await fh.read(buf, 0, len, cached.offset);
286
+ } finally {
287
+ await fh.close();
288
+ }
289
+
290
+ // Stop at the last complete newline — a partial trailing line (still
291
+ // being written) is left unconsumed so the next pass re-reads it once
292
+ // it's whole, instead of permanently skipping or mis-parsing it.
293
+ const lastNl = buf.lastIndexOf(0x0a);
294
+ const map = new Map(cached.map);
295
+ let offset = cached.offset;
296
+ if (lastNl !== -1) {
297
+ applyTerminalLines(buf.subarray(0, lastNl + 1).toString('utf8'), map);
298
+ offset = cached.offset + lastNl + 1;
299
+ }
300
+ const updated = { ino, size, offset, map };
301
+ perFileCache.set(target, updated);
302
+ return map;
303
+ }
304
+
305
+ // Full (re)parse: first time seeing this path, its inode changed, or it
306
+ // shrank (log rotation/truncation) — the existing offset is no longer
307
+ // trustworthy.
308
+ let text = '';
309
+ try {
310
+ text = await fsp.readFile(target, 'utf8');
311
+ } catch (e) {
312
+ if (e.code !== 'ENOENT') throw e;
313
+ }
314
+ const map = new Map();
315
+ applyTerminalLines(text, map);
316
+ const lastNl = Buffer.from(text, 'utf8').lastIndexOf(0x0a);
317
+ const offset = lastNl === -1 ? 0 : lastNl + 1;
318
+ perFileCache.set(target, { ino, size, offset, map });
319
+ return map;
320
+ }
321
+
239
322
  /**
240
323
  * Full-file scan of history.jsonl, returning slug -> { status, finishedAt }
241
324
  * for the most recently appended record of each slug. O(H) where H is the
242
325
  * line count of history.jsonl; cached by the file's mtime so a reconcile()
243
- * pass with no new archives since the last call is O(1).
326
+ * pass with no new archives since the last call is O(1). When the mtime
327
+ * check misses, each file is re-read incrementally via readFileTerminalMap
328
+ * rather than re-parsed from byte zero.
244
329
  *
245
330
  * Exists so reconcile() can tell "this on-disk PRD's job row already left
246
331
  * jobs[] into history — do not resurrect it as a fresh pending job" apart
@@ -248,36 +333,22 @@ let historyCacheMap = null;
248
333
  */
249
334
  async function historyTerminalBySlug() {
250
335
  const paths = historyPaths();
251
- const mtimes = await Promise.all(paths.map(async (p2) => {
252
- try { return (await fsp.stat(p2)).mtimeMs; } catch { return -1; }
336
+ const stats = await Promise.all(paths.map(async (p2) => {
337
+ try { return await fsp.stat(p2); } catch { return null; }
253
338
  }));
254
- const cacheKey = paths.map((p2, i) => `${p2}:${mtimes[i]}`).join('|');
339
+ const cacheKey = paths.map((p2, i) => `${p2}:${stats[i] ? stats[i].mtimeMs : -1}`).join('|');
255
340
  if (cacheKey === historyCacheKey && historyCacheMap) return historyCacheMap;
256
341
 
257
342
  const map = new Map();
258
- for (const p2 of paths) {
259
- let text = '';
260
- try {
261
- text = await fsp.readFile(p2, 'utf8');
262
- } catch (e) {
263
- if (e.code !== 'ENOENT') throw e;
264
- }
265
- if (!text) continue;
266
- for (const line of text.split('\n')) {
267
- if (!line.trim()) continue;
268
- try {
269
- const j = JSON.parse(line);
270
- // needs_review_entry/needs_review_resolution lines (needsReviewLedger.cjs)
271
- // share this file but aren't a job's terminal record — skip them, or a
272
- // needs_review episode logged for a slug AFTER its real terminal row
273
- // (e.g. it re-parked on a later run) would overwrite a true 'completed'
274
- // with a kind-less, status-less row read as "not terminal".
275
- if (j?.kind && j.kind !== 'terminal') continue;
276
- if (j?.slug) map.set(j.slug, { status: j.status, finishedAt: j.finishedAt, landedCommit: j.landedCommit ?? null });
277
- } catch {
278
- // corrupt/partial line — ignore
279
- }
343
+ for (let i = 0; i < paths.length; i++) {
344
+ const p2 = paths[i];
345
+ const stat = stats[i];
346
+ if (!stat) {
347
+ perFileCache.delete(p2);
348
+ continue;
280
349
  }
350
+ const fileMap = await readFileTerminalMap(p2, stat);
351
+ for (const [slug, rec] of fileMap) map.set(slug, rec);
281
352
  }
282
353
  historyCacheKey = cacheKey;
283
354
  historyCacheMap = map;