claude-code-session-manager 0.11.1 → 0.12.1

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 (28) hide show
  1. package/dist/assets/{TiptapBody-CRmtVlnv.js → TiptapBody-BPrQGByq.js} +1 -1
  2. package/dist/assets/{cssMode-jKB9FuI1.js → cssMode-BqupsQhT.js} +1 -1
  3. package/dist/assets/{freemarker2-BPjwx9LW.js → freemarker2-0gDz5OsZ.js} +1 -1
  4. package/dist/assets/{handlebars-BLoV3dqv.js → handlebars-SmU_cMQ5.js} +1 -1
  5. package/dist/assets/{html-D-6YItp_.js → html-703DN3jh.js} +1 -1
  6. package/dist/assets/{htmlMode-Dtc92cqk.js → htmlMode-DGXHhxM8.js} +1 -1
  7. package/dist/assets/{index-4x5C_duH.css → index-LyCQu6Cl.css} +1 -1
  8. package/dist/assets/{index-DG1rozxP.js → index-z93ZHKU-.js} +922 -921
  9. package/dist/assets/{javascript-BQcvFYdn.js → javascript-CCqlYcRz.js} +1 -1
  10. package/dist/assets/{jsonMode-CnFmKTUr.js → jsonMode-D1sLC2q8.js} +1 -1
  11. package/dist/assets/{liquid-ogD2CQU1.js → liquid-CyzWwa9e.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-DKVkL-Ro.js → lspLanguageFeatures-CgBUaMwZ.js} +1 -1
  13. package/dist/assets/{mdx-BHbTIp7G.js → mdx-iOgDcliU.js} +1 -1
  14. package/dist/assets/{python-fa-8uToX.js → python-DW3A0NRm.js} +1 -1
  15. package/dist/assets/{razor-D4OJJrW7.js → razor-B9yGdjxE.js} +1 -1
  16. package/dist/assets/{tsMode-J9ZwPyiC.js → tsMode-DNdyB0Pq.js} +1 -1
  17. package/dist/assets/{typescript-1H3_b_hk.js → typescript-DDsE6PK5.js} +1 -1
  18. package/dist/assets/{xml-Djys1Yq_.js → xml-CRN2mPLm.js} +1 -1
  19. package/dist/assets/{yaml-CAZ0iMcZ.js → yaml-BwgW9kTw.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/hives.cjs +226 -0
  23. package/src/main/index.cjs +6 -0
  24. package/src/main/ipcSchemas.cjs +5 -0
  25. package/src/main/repoAnalyzer.cjs +346 -0
  26. package/src/main/search.cjs +337 -0
  27. package/src/preload/api.d.ts +66 -0
  28. package/src/preload/index.cjs +13 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Search IPC — fuzzy file path search + content grep.
3
+ *
4
+ * - Prefers ripgrep (`rg`) when present on PATH for both fast file enumeration
5
+ * (`rg --files`) and content search (`rg --json`). Falls back to a Node
6
+ * fs-based recursive walk capped at depth 10 when rg is missing.
7
+ * - All cwds are routed through `config.cjs`'s `validatePath` so the renderer
8
+ * can never search outside the home directory.
9
+ * - 10s wall-clock ceiling per call — search runs that overflow are killed
10
+ * and return partial results.
11
+ * - Ignored dirs (both modes): .git / node_modules / dist / build / .next /
12
+ * .cache / coverage / .turbo.
13
+ *
14
+ * Designed to back two renderer modals: QuickOpen (Cmd+P) and GlobalSearch
15
+ * (Cmd+Shift+F).
16
+ */
17
+
18
+ const { ipcMain } = require('electron');
19
+ const { spawn } = require('node:child_process');
20
+ const fsp = require('node:fs/promises');
21
+ const path = require('node:path');
22
+ const os = require('node:os');
23
+ const { z } = require('zod');
24
+
25
+ const { validatePath } = require('./config.cjs');
26
+
27
+ const TIMEOUT_MS = 10_000;
28
+ const MAX_FILES = 5000;
29
+ const MAX_TEXT_MATCHES = 500;
30
+ const MAX_WALK_DEPTH = 10;
31
+ const IGNORED_DIRS = new Set([
32
+ '.git', 'node_modules', 'dist', 'build', '.next',
33
+ '.cache', 'coverage', '.turbo', '.parcel-cache', '.pnpm-store',
34
+ ]);
35
+
36
+ // ── ripgrep detection ─────────────────────────────────────────────────────
37
+ let _rgAvailable = null; // null = unknown, true/false = cached
38
+ function probeRipgrep() {
39
+ if (_rgAvailable !== null) return _rgAvailable;
40
+ return new Promise((resolve) => {
41
+ let resolved = false;
42
+ const done = (val) => { if (!resolved) { resolved = true; _rgAvailable = val; resolve(val); } };
43
+ try {
44
+ const proc = spawn('rg', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
45
+ proc.on('error', () => done(false));
46
+ proc.on('exit', (code) => done(code === 0));
47
+ // Hard timeout: probe shouldn't take more than 1s.
48
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } done(false); }, 1000);
49
+ } catch {
50
+ done(false);
51
+ }
52
+ });
53
+ }
54
+
55
+ // ── helpers ───────────────────────────────────────────────────────────────
56
+ function expandHome(p) {
57
+ if (!p) return p;
58
+ if (p === '~') return os.homedir();
59
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
60
+ return p;
61
+ }
62
+
63
+ /**
64
+ * Spawn rg with argv (no shell), capture stdout/stderr, enforce TIMEOUT_MS.
65
+ * Resolves to { ok, stdout, stderr, exitCode, timedOut }.
66
+ */
67
+ function runRg(args, cwd) {
68
+ return new Promise((resolve) => {
69
+ let stdout = '';
70
+ let stderr = '';
71
+ let timedOut = false;
72
+ let proc;
73
+ try {
74
+ proc = spawn('rg', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
75
+ } catch (e) {
76
+ resolve({ ok: false, stdout: '', stderr: e.message, exitCode: -1, timedOut: false });
77
+ return;
78
+ }
79
+ const timer = setTimeout(() => {
80
+ timedOut = true;
81
+ try { proc.kill('SIGTERM'); } catch { /* noop */ }
82
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, 500);
83
+ }, TIMEOUT_MS);
84
+
85
+ proc.stdout.on('data', (chunk) => {
86
+ stdout += chunk.toString('utf8');
87
+ // Soft cap on buffer growth — if rg yields more than 10 MB of stdout
88
+ // something is wrong with the query; just kill it.
89
+ if (stdout.length > 10 * 1024 * 1024) {
90
+ try { proc.kill('SIGTERM'); } catch { /* noop */ }
91
+ }
92
+ });
93
+ proc.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
94
+ proc.on('error', (e) => {
95
+ clearTimeout(timer);
96
+ resolve({ ok: false, stdout, stderr: e.message, exitCode: -1, timedOut });
97
+ });
98
+ proc.on('exit', (code) => {
99
+ clearTimeout(timer);
100
+ // rg exits 1 when zero matches found — that's still "ok" for us.
101
+ resolve({ ok: code === 0 || code === 1, stdout, stderr, exitCode: code ?? -1, timedOut });
102
+ });
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Recursive fs walk capped at MAX_WALK_DEPTH and ignoring IGNORED_DIRS.
108
+ * Returns absolute paths. Bails out early at MAX_FILES.
109
+ */
110
+ async function fsWalk(rootAbs) {
111
+ const out = [];
112
+ const startedAt = Date.now();
113
+ async function walk(dir, depth) {
114
+ if (depth > MAX_WALK_DEPTH) return;
115
+ if (out.length >= MAX_FILES) return;
116
+ if (Date.now() - startedAt > TIMEOUT_MS) return;
117
+ let entries;
118
+ try {
119
+ entries = await fsp.readdir(dir, { withFileTypes: true });
120
+ } catch {
121
+ return;
122
+ }
123
+ for (const entry of entries) {
124
+ if (out.length >= MAX_FILES) return;
125
+ if (entry.name.startsWith('.') && entry.name !== '.env') {
126
+ // Skip dotfiles by default — matches rg's defaults closely enough
127
+ // for our use case. .env is a common exception users may want.
128
+ if (entry.isDirectory()) continue;
129
+ }
130
+ if (entry.isDirectory()) {
131
+ if (IGNORED_DIRS.has(entry.name)) continue;
132
+ await walk(path.join(dir, entry.name), depth + 1);
133
+ } else if (entry.isFile()) {
134
+ out.push(path.join(dir, entry.name));
135
+ }
136
+ }
137
+ }
138
+ await walk(rootAbs, 0);
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Naive content grep for the rg-fallback path. Reads each file up to 1 MB,
144
+ * splits on newlines, returns the first MAX_TEXT_MATCHES occurrences. Skips
145
+ * binary files heuristically (NUL byte in first 4 KiB).
146
+ */
147
+ async function fsGrep(rootAbs, needle, opts) {
148
+ const caseSensitive = !!opts.caseSensitive;
149
+ const limit = Math.max(1, Math.min(MAX_TEXT_MATCHES, opts.limit ?? MAX_TEXT_MATCHES));
150
+ const haystack = caseSensitive ? needle : needle.toLowerCase();
151
+ const startedAt = Date.now();
152
+ const files = await fsWalk(rootAbs);
153
+ const matches = [];
154
+ for (const file of files) {
155
+ if (matches.length >= limit) break;
156
+ if (Date.now() - startedAt > TIMEOUT_MS) break;
157
+ let buf;
158
+ try {
159
+ const st = await fsp.stat(file);
160
+ if (st.size > 1024 * 1024) continue;
161
+ buf = await fsp.readFile(file);
162
+ } catch { continue; }
163
+ // Binary sniff.
164
+ const sniff = buf.slice(0, 4096);
165
+ if (sniff.includes(0)) continue;
166
+ const text = buf.toString('utf8');
167
+ const lines = text.split('\n');
168
+ for (let i = 0; i < lines.length; i++) {
169
+ if (matches.length >= limit) break;
170
+ const line = lines[i];
171
+ const target = caseSensitive ? line : line.toLowerCase();
172
+ const idx = target.indexOf(haystack);
173
+ if (idx !== -1) {
174
+ matches.push({
175
+ path: file,
176
+ line: i + 1,
177
+ column: idx + 1,
178
+ text: line.length > 400 ? line.slice(0, 400) + '…' : line,
179
+ });
180
+ }
181
+ }
182
+ }
183
+ return matches;
184
+ }
185
+
186
+ // ── public API: search:files ──────────────────────────────────────────────
187
+ async function searchFiles({ cwd, query, opts }) {
188
+ const limit = Math.max(1, Math.min(MAX_FILES, opts?.limit ?? MAX_FILES));
189
+ let resolved;
190
+ try { resolved = validatePath(expandHome(cwd)); }
191
+ catch (e) { return { ok: false, files: [], error: e.message, usedRipgrep: false }; }
192
+
193
+ const usedRipgrep = await probeRipgrep();
194
+ let absPaths = [];
195
+ if (usedRipgrep) {
196
+ // `rg --files` enumerates the tree honoring .gitignore. We add explicit
197
+ // --glob '!dir' exclusions on top in case the repo isn't a git repo.
198
+ const args = [
199
+ '--files', '--hidden', '--follow',
200
+ '--max-filesize', '5M',
201
+ ];
202
+ for (const dir of IGNORED_DIRS) {
203
+ args.push('--glob', `!**/${dir}/**`);
204
+ }
205
+ const result = await runRg(args, resolved);
206
+ if (result.timedOut) {
207
+ return { ok: false, files: [], error: 'search timeout (10s)', usedRipgrep: true };
208
+ }
209
+ if (!result.ok) {
210
+ return { ok: false, files: [], error: result.stderr || `rg exit ${result.exitCode}`, usedRipgrep: true };
211
+ }
212
+ absPaths = result.stdout.split('\n')
213
+ .filter((l) => l.length > 0)
214
+ .map((rel) => path.join(resolved, rel));
215
+ } else {
216
+ absPaths = await fsWalk(resolved);
217
+ }
218
+
219
+ // Fuzzy filter / sort happens in renderer. Main returns the candidate set.
220
+ // When query is provided we do a coarse case-insensitive substring filter
221
+ // to cut payload size — renderer's fuzzy ranker handles the precise scoring.
222
+ if (query && query.length > 0) {
223
+ const q = query.toLowerCase();
224
+ absPaths = absPaths.filter((p) => p.toLowerCase().includes(q));
225
+ }
226
+ absPaths = absPaths.slice(0, limit);
227
+
228
+ // Shape each entry like files:list so QuickOpen can reuse FileNode types.
229
+ const files = absPaths.map((abs) => ({
230
+ name: path.basename(abs),
231
+ path: abs,
232
+ isDirectory: false,
233
+ isFile: true,
234
+ }));
235
+ return { ok: true, files, error: null, usedRipgrep };
236
+ }
237
+
238
+ // ── public API: search:text ───────────────────────────────────────────────
239
+ async function searchText({ cwd, query, opts }) {
240
+ const limit = Math.max(1, Math.min(MAX_TEXT_MATCHES, opts?.limit ?? MAX_TEXT_MATCHES));
241
+ const caseSensitive = !!opts?.caseSensitive;
242
+ if (!query || query.length === 0) {
243
+ return { ok: true, matches: [], error: null, usedRipgrep: false };
244
+ }
245
+ let resolved;
246
+ try { resolved = validatePath(expandHome(cwd)); }
247
+ catch (e) { return { ok: false, matches: [], error: e.message, usedRipgrep: false }; }
248
+
249
+ const usedRipgrep = await probeRipgrep();
250
+ if (!usedRipgrep) {
251
+ const matches = await fsGrep(resolved, query, { caseSensitive, limit });
252
+ return { ok: true, matches, error: null, usedRipgrep: false };
253
+ }
254
+
255
+ // rg --json gives us { type, data: { ... } } lines. We use --fixed-strings
256
+ // so users don't accidentally trigger regex on `.` or `(`. Renderer can
257
+ // toggle regex later if we ever surface that option.
258
+ const args = [
259
+ '--json', '--fixed-strings', '--hidden', '--follow',
260
+ '--max-filesize', '5M',
261
+ '--max-count', String(limit),
262
+ ];
263
+ if (!caseSensitive) args.push('--ignore-case');
264
+ for (const dir of IGNORED_DIRS) {
265
+ args.push('--glob', `!**/${dir}/**`);
266
+ }
267
+ args.push('--', query);
268
+
269
+ const result = await runRg(args, resolved);
270
+ if (result.timedOut) {
271
+ return { ok: false, matches: [], error: 'search timeout (10s)', usedRipgrep: true };
272
+ }
273
+ if (!result.ok) {
274
+ return { ok: false, matches: [], error: result.stderr || `rg exit ${result.exitCode}`, usedRipgrep: true };
275
+ }
276
+
277
+ const matches = [];
278
+ const lines = result.stdout.split('\n');
279
+ for (const raw of lines) {
280
+ if (matches.length >= limit) break;
281
+ if (!raw) continue;
282
+ let evt;
283
+ try { evt = JSON.parse(raw); } catch { continue; }
284
+ if (evt.type !== 'match') continue;
285
+ const data = evt.data;
286
+ if (!data) continue;
287
+ const rel = data.path?.text;
288
+ if (!rel) continue;
289
+ const abs = path.isAbsolute(rel) ? rel : path.join(resolved, rel);
290
+ const lineNum = data.line_number ?? 0;
291
+ let text = data.lines?.text ?? '';
292
+ if (text.endsWith('\n')) text = text.slice(0, -1);
293
+ if (text.length > 400) text = text.slice(0, 400) + '…';
294
+ const column = (data.submatches && data.submatches[0]?.start != null)
295
+ ? data.submatches[0].start + 1
296
+ : 1;
297
+ matches.push({ path: abs, line: lineNum, column, text });
298
+ }
299
+ return { ok: true, matches, error: null, usedRipgrep: true };
300
+ }
301
+
302
+ // ── IPC registration ──────────────────────────────────────────────────────
303
+ const searchFilesSchema = z.object({
304
+ cwd: z.string().min(1).max(4096),
305
+ query: z.string().max(1024).optional().default(''),
306
+ opts: z.object({
307
+ limit: z.number().int().min(1).max(MAX_FILES).optional(),
308
+ }).optional(),
309
+ });
310
+
311
+ const searchTextSchema = z.object({
312
+ cwd: z.string().min(1).max(4096),
313
+ query: z.string().min(1).max(1024),
314
+ opts: z.object({
315
+ limit: z.number().int().min(1).max(MAX_TEXT_MATCHES).optional(),
316
+ caseSensitive: z.boolean().optional(),
317
+ }).optional(),
318
+ });
319
+
320
+ function registerSearchHandlers() {
321
+ ipcMain.handle('search:files', (_e, payload) => {
322
+ const parsed = searchFilesSchema.parse(payload);
323
+ return searchFiles(parsed);
324
+ });
325
+ ipcMain.handle('search:text', (_e, payload) => {
326
+ const parsed = searchTextSchema.parse(payload);
327
+ return searchText(parsed);
328
+ });
329
+ }
330
+
331
+ module.exports = {
332
+ registerSearchHandlers,
333
+ // exported for tests
334
+ searchFiles,
335
+ searchText,
336
+ probeRipgrep,
337
+ };
@@ -417,6 +417,59 @@ export interface FilesRenameResult { ok: boolean; newPath?: string; error: strin
417
417
  export interface FilesDeleteResult { ok: boolean; error: string | null }
418
418
  export interface FilesShellResult { ok: boolean; error?: string }
419
419
 
420
+ export interface SearchFileEntry {
421
+ name: string;
422
+ path: string;
423
+ isDirectory: false;
424
+ isFile: true;
425
+ }
426
+ export interface SearchFilesResult {
427
+ ok: boolean;
428
+ files: SearchFileEntry[];
429
+ error: string | null;
430
+ usedRipgrep: boolean;
431
+ }
432
+ export interface SearchTextMatch {
433
+ path: string;
434
+ line: number;
435
+ column: number;
436
+ text: string;
437
+ }
438
+ export interface SearchTextResult {
439
+ ok: boolean;
440
+ matches: SearchTextMatch[];
441
+ error: string | null;
442
+ usedRipgrep: boolean;
443
+ }
444
+
445
+ export interface RepoLanguageStats { files: number; lines: number }
446
+ export interface RepoTopDirectory { path: string; fileCount: number; totalLines: number }
447
+ export interface RepoGitStatusSummary { uncommitted: number; branch: string | null }
448
+ export interface RepoAnalyzeResult {
449
+ ok: true;
450
+ cwd: string;
451
+ totalFiles: number;
452
+ totalLines: number;
453
+ languageBreakdown: Record<string, RepoLanguageStats>;
454
+ topDirectories: RepoTopDirectory[];
455
+ gitStatus: RepoGitStatusSummary;
456
+ truncated: boolean;
457
+ durationMs: number;
458
+ }
459
+ export interface RepoAnalyzeError { ok: false; error: string }
460
+
461
+ export interface HiveRole { label: string; prompt: string }
462
+ export interface Hive {
463
+ slug: string;
464
+ name: string;
465
+ description: string;
466
+ roles: HiveRole[];
467
+ defaultPlan?: string;
468
+ }
469
+ export interface HiveListResult { hives: Hive[]; error: string | null }
470
+ export interface HiveGetResult { hive: Hive | null; error: string | null }
471
+ export interface HiveMutationResult { ok: boolean; error: string | null }
472
+
420
473
  export interface WatcherInfo {
421
474
  watcherId: string;
422
475
  tabId: string;
@@ -788,6 +841,19 @@ export interface SessionManagerAPI {
788
841
  openExternal: (path: string) => Promise<FilesShellResult>;
789
842
  showInFinder: (path: string) => Promise<FilesShellResult>;
790
843
  };
844
+ search: {
845
+ files: (cwd: string, query?: string, opts?: { limit?: number }) => Promise<SearchFilesResult>;
846
+ text: (cwd: string, query: string, opts?: { limit?: number; caseSensitive?: boolean }) => Promise<SearchTextResult>;
847
+ };
848
+ repo: {
849
+ analyze: (cwd: string) => Promise<RepoAnalyzeResult | RepoAnalyzeError>;
850
+ };
851
+ hives: {
852
+ list: () => Promise<HiveListResult>;
853
+ get: (slug: string) => Promise<HiveGetResult>;
854
+ save: (slug: string, hive: Hive) => Promise<HiveMutationResult>;
855
+ delete: (slug: string) => Promise<HiveMutationResult>;
856
+ };
791
857
  history: {
792
858
  aggregate: (req?: HistoryAggregateRequest) => Promise<HistoryAggregateResult>;
793
859
  listConversations: () => Promise<ListConversationsResult>;
@@ -163,6 +163,19 @@ contextBridge.exposeInMainWorld('api', {
163
163
  openExternal: (path) => ipcRenderer.invoke('files:open-external', { path }),
164
164
  showInFinder: (path) => ipcRenderer.invoke('files:show-in-finder', { path }),
165
165
  },
166
+ search: {
167
+ files: (cwd, query, opts) => ipcRenderer.invoke('search:files', { cwd, query, opts }),
168
+ text: (cwd, query, opts) => ipcRenderer.invoke('search:text', { cwd, query, opts }),
169
+ },
170
+ repo: {
171
+ analyze: (cwd) => ipcRenderer.invoke('repo:analyze', { cwd }),
172
+ },
173
+ hives: {
174
+ list: () => ipcRenderer.invoke('hives:list'),
175
+ get: (slug) => ipcRenderer.invoke('hives:get', { slug }),
176
+ save: (slug, hive) => ipcRenderer.invoke('hives:save', { slug, hive }),
177
+ delete: (slug) => ipcRenderer.invoke('hives:delete', { slug }),
178
+ },
166
179
  schedule: {
167
180
  state: () => ipcRenderer.invoke('schedule:state'),
168
181
  setConfig: (partial) => ipcRenderer.invoke('schedule:set-config', partial),