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,226 @@
1
+ /**
2
+ * hives.cjs — pre-baked subagent swarm templates ("Hives").
3
+ *
4
+ * A Hive is a named collection of subagent roles + an optional default plan,
5
+ * launchable as one unit. Concept ported from ClaudeCodeUnleashed; our shape
6
+ * is `{ slug, name, description, roles: [{ label, prompt }], defaultPlan? }`
7
+ * (renderer launches by configuring Orchestrator with those roles).
8
+ *
9
+ * Storage: `~/.claude/session-manager/hives/<slug>.json`
10
+ * - slug must match SLUG_RE: /^[a-z0-9-_]{1,64}$/
11
+ * - up to 32 roles per hive
12
+ * - per-field byte caps mirrored in the inline zod schemas below
13
+ *
14
+ * IPC namespace:
15
+ * - hives:list -> { hives: Hive[], error: string | null }
16
+ * - hives:get -> { hive: Hive | null, error: string | null }
17
+ * - hives:save -> { ok: boolean, error: string | null }
18
+ * - hives:delete -> { ok: boolean, error: string | null }
19
+ *
20
+ * All mutations go through config.cjs::writeJson (atomic tmp+rename) and
21
+ * config.cjs::validatePath (allowedRoots = home dir). Never raw fs.writeFile.
22
+ *
23
+ * Default hives (Code review / Build feature / Bug hunt) ship in the renderer
24
+ * (src/renderer/lib/defaultHives.ts) and are NOT writable to disk — they exist
25
+ * only as in-memory starter examples so a fresh install has content. The IPC
26
+ * layer only sees user-saved hives.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const { ipcMain } = require('electron');
32
+ const fsp = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const os = require('node:os');
35
+ const { z } = require('zod');
36
+ const config = require('./config.cjs');
37
+
38
+ // ──────────────────────────────────────────── caps
39
+ const SLUG_RE = /^[a-z0-9-_]{1,64}$/;
40
+ const MAX_NAME_LEN = 128;
41
+ const MAX_DESC_LEN = 2048;
42
+ const MAX_LABEL_LEN = 128;
43
+ const MAX_PROMPT_LEN = 16 * 1024;
44
+ const MAX_PLAN_LEN = 8 * 1024;
45
+ const MAX_ROLES = 32;
46
+
47
+ // ──────────────────────────────────────────── inline zod schemas
48
+ const hiveRoleSchema = z.object({
49
+ label: z.string().min(1).max(MAX_LABEL_LEN),
50
+ prompt: z.string().min(1).max(MAX_PROMPT_LEN),
51
+ }).strict();
52
+
53
+ const hiveSchema = z.object({
54
+ slug: z.string().regex(SLUG_RE),
55
+ name: z.string().min(1).max(MAX_NAME_LEN),
56
+ description: z.string().max(MAX_DESC_LEN).default(''),
57
+ roles: z.array(hiveRoleSchema).min(1).max(MAX_ROLES),
58
+ defaultPlan: z.string().max(MAX_PLAN_LEN).optional(),
59
+ }).strict();
60
+
61
+ const slugPayload = z.object({
62
+ slug: z.string().regex(SLUG_RE),
63
+ }).strict();
64
+
65
+ const savePayload = z.object({
66
+ slug: z.string().regex(SLUG_RE),
67
+ hive: hiveSchema,
68
+ }).strict();
69
+
70
+ // ──────────────────────────────────────────── paths
71
+ function rootDir() {
72
+ return path.join(os.homedir(), '.claude', 'session-manager', 'hives');
73
+ }
74
+
75
+ function hivePath(slug) {
76
+ if (!SLUG_RE.test(slug)) {
77
+ throw new Error(`invalid hive slug (must match ${SLUG_RE.source})`);
78
+ }
79
+ return path.join(rootDir(), `${slug}.json`);
80
+ }
81
+
82
+ async function ensureRoot() {
83
+ await fsp.mkdir(rootDir(), { recursive: true });
84
+ }
85
+
86
+ // ──────────────────────────────────────────── core ops
87
+ async function listHives() {
88
+ try {
89
+ await ensureRoot();
90
+ const r = await config.listDir(rootDir(), { filesOnly: true });
91
+ if (!r.ok) return { hives: [], error: r.error };
92
+ const slugs = r.entries
93
+ .filter((e) => e.name.endsWith('.json'))
94
+ .map((e) => e.name.replace(/\.json$/, ''))
95
+ .filter((s) => SLUG_RE.test(s));
96
+ // Load each in parallel. Skip any that fail to parse cleanly.
97
+ const hives = await Promise.all(
98
+ slugs.map(async (slug) => {
99
+ try {
100
+ const got = await readHive(slug);
101
+ return got.hive;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }),
106
+ );
107
+ return {
108
+ hives: hives.filter((h) => h !== null).sort((a, b) => a.slug.localeCompare(b.slug)),
109
+ error: null,
110
+ };
111
+ } catch (e) {
112
+ return { hives: [], error: e.message };
113
+ }
114
+ }
115
+
116
+ async function readHive(slug) {
117
+ const abs = hivePath(slug);
118
+ const r = await config.readJson(abs);
119
+ if (!r || !r.exists) return { hive: null, error: null };
120
+ if (r.parseError) return { hive: null, error: `parse error: ${r.parseError}` };
121
+ // Re-validate stored shape so a hand-edited file can't smuggle in bad data.
122
+ const parsed = hiveSchema.safeParse(r.data);
123
+ if (!parsed.success) {
124
+ return { hive: null, error: `invalid hive on disk: ${parsed.error.issues[0]?.message ?? 'unknown'}` };
125
+ }
126
+ // Trust the file's own slug only if it matches the filename; otherwise force
127
+ // them to agree (filename wins — that's the storage key).
128
+ const hive = { ...parsed.data, slug };
129
+ return { hive, error: null };
130
+ }
131
+
132
+ async function getHive(slug) {
133
+ try {
134
+ return await readHive(slug);
135
+ } catch (e) {
136
+ return { hive: null, error: e.message };
137
+ }
138
+ }
139
+
140
+ async function saveHive(slug, hive) {
141
+ try {
142
+ // Body slug must match path slug. Reject mismatches loudly so the renderer
143
+ // can't accidentally overwrite the wrong file by tampering with `slug`.
144
+ if (hive.slug !== slug) {
145
+ return { ok: false, error: `slug mismatch: payload slug "${hive.slug}" != path "${slug}"` };
146
+ }
147
+ await ensureRoot();
148
+ const abs = hivePath(slug);
149
+ const out = {
150
+ ...hive,
151
+ // Strip undefined for clean JSON on disk.
152
+ description: hive.description ?? '',
153
+ };
154
+ if (out.defaultPlan === undefined || out.defaultPlan === '') delete out.defaultPlan;
155
+ const result = await config.writeJson(abs, out);
156
+ if (!result || !result.ok) {
157
+ return { ok: false, error: result?.error ?? 'write failed' };
158
+ }
159
+ return { ok: true, error: null };
160
+ } catch (e) {
161
+ return { ok: false, error: e.message };
162
+ }
163
+ }
164
+
165
+ async function deleteHive(slug) {
166
+ try {
167
+ const abs = hivePath(slug);
168
+ let real;
169
+ try {
170
+ real = config.validatePath(abs);
171
+ if (typeof config.validateWrite === 'function') {
172
+ config.validateWrite(real);
173
+ }
174
+ } catch (e) {
175
+ return { ok: false, error: e.message };
176
+ }
177
+ try {
178
+ await fsp.unlink(real);
179
+ } catch (e) {
180
+ if (e.code === 'ENOENT') {
181
+ // Treat missing as success — idempotent delete, same as fs unlink ENOENT.
182
+ return { ok: true, error: null };
183
+ }
184
+ return { ok: false, error: e.message };
185
+ }
186
+ return { ok: true, error: null };
187
+ } catch (e) {
188
+ return { ok: false, error: e.message };
189
+ }
190
+ }
191
+
192
+ // ──────────────────────────────────────────── IPC
193
+ function validated(schema, handler) {
194
+ return (_event, payload) => {
195
+ const parsed = schema.parse(payload);
196
+ return handler(parsed);
197
+ };
198
+ }
199
+
200
+ function registerHiveHandlers() {
201
+ ipcMain.handle('hives:list', () => listHives());
202
+ ipcMain.handle(
203
+ 'hives:get',
204
+ validated(slugPayload, ({ slug }) => getHive(slug)),
205
+ );
206
+ ipcMain.handle(
207
+ 'hives:save',
208
+ validated(savePayload, ({ slug, hive }) => saveHive(slug, hive)),
209
+ );
210
+ ipcMain.handle(
211
+ 'hives:delete',
212
+ validated(slugPayload, ({ slug }) => deleteHive(slug)),
213
+ );
214
+ }
215
+
216
+ module.exports = {
217
+ registerHiveHandlers,
218
+ // exported for tests
219
+ rootDir,
220
+ hivePath,
221
+ SLUG_RE,
222
+ listHives,
223
+ getHive,
224
+ saveHive,
225
+ deleteHive,
226
+ };
@@ -30,6 +30,9 @@ const git = require('./git.cjs');
30
30
  const superagent = require('./superagent.cjs');
31
31
  const { registerProjectSkillsHandlers } = require('./projectSkills.cjs');
32
32
  const filesIpc = require('./files.cjs');
33
+ const searchIpc = require('./search.cjs');
34
+ const repoAnalyzer = require('./repoAnalyzer.cjs');
35
+ const hivesIpc = require('./hives.cjs');
33
36
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
34
37
  const { assertCwdInsideHome } = require('./lib/insideHome.cjs');
35
38
 
@@ -642,6 +645,9 @@ git.register(ipcMain);
642
645
  superagent.registerSuperAgentHandlers();
643
646
  registerProjectSkillsHandlers();
644
647
  filesIpc.registerFilesHandlers();
648
+ searchIpc.registerSearchHandlers();
649
+ repoAnalyzer.register(ipcMain);
650
+ hivesIpc.registerHiveHandlers();
645
651
 
646
652
  // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
647
653
  ipcMain.handle('otel:get-config', async () => otelSettings.load());
@@ -323,6 +323,10 @@ const gitFileStatus = z.object({
323
323
  cwd: z.string().min(1).max(4096),
324
324
  }).passthrough();
325
325
 
326
+ const repoAnalyze = z.object({
327
+ cwd: z.string().min(1).max(4096),
328
+ }).passthrough();
329
+
326
330
  // Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
327
331
  // depth — install() re-checks; the schema rejects earlier.
328
332
  const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
@@ -398,6 +402,7 @@ module.exports = {
398
402
  appGitBranch,
399
403
  gitStatus,
400
404
  gitFileStatus,
405
+ repoAnalyze,
401
406
  pluginsInstall,
402
407
  superagentStart,
403
408
  superagentTabId,
@@ -0,0 +1,346 @@
1
+ /**
2
+ * repo:analyze(cwd) — walk a project tree and return a compact stats snapshot
3
+ * for the RepoVisualizationModal.
4
+ *
5
+ * Ported in spirit from ClaudeCodeUnleashed's src/main/ipc/repoAnalyzer.ts, but
6
+ * stripped down to what we actually surface (no dependency listing, no full
7
+ * tree return, no file-content fetch). The renderer renders:
8
+ * - language breakdown bar (extension → file count, line count)
9
+ * - top directories treemap
10
+ * - uncommitted-file badge from git:status
11
+ * so the IPC return shape stays in lockstep with what the modal needs.
12
+ *
13
+ * Security:
14
+ * - cwd is validatePath'd (allowedRoots = home). Never follows symlinks
15
+ * (lstat-based; dirent.isSymbolicLink() check on every entry).
16
+ * - Hard ceilings: 30s wall-clock, 5000 files, 100KB per-file line counting.
17
+ * - Reads only — no writes, no spawn.
18
+ *
19
+ * Complexity: O(N) entries where N = files+dirs visited (bounded by
20
+ * MAX_FILES_TO_ANALYZE). Tail-recursion stack depth is bounded by
21
+ * MAX_DEPTH (10) so no stack hazards on pathologically nested trees.
22
+ */
23
+
24
+ const fsp = require('node:fs/promises');
25
+ const path = require('node:path');
26
+ const { validatePath } = require('./config.cjs');
27
+ const { schemas } = require('./ipcSchemas.cjs');
28
+ const gitMod = require('./git.cjs');
29
+
30
+ // Directories to skip — `.git`, common build / dep / cache dirs.
31
+ const IGNORE_DIRS = new Set([
32
+ '.git', 'node_modules', 'dist', 'build', 'out', '.next', '.nuxt',
33
+ 'coverage', '.cache', '.vscode', '.idea', '__pycache__', 'venv',
34
+ '.env', 'target', 'vendor', 'bower_components',
35
+ '.turbo', '.parcel-cache', '.svelte-kit', '.angular',
36
+ ]);
37
+
38
+ // Filenames to skip outright (lockfiles, OS detritus).
39
+ const IGNORE_FILES = new Set([
40
+ '.DS_Store', 'Thumbs.db',
41
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
42
+ ]);
43
+
44
+ // Per-extension display name. Anything not in here lumps under 'other'.
45
+ const LANG_LABELS = {
46
+ '.ts': 'TypeScript',
47
+ '.tsx': 'TSX',
48
+ '.js': 'JavaScript',
49
+ '.jsx': 'JSX',
50
+ '.cjs': 'CommonJS',
51
+ '.mjs': 'ES Module',
52
+ '.json': 'JSON',
53
+ '.css': 'CSS',
54
+ '.scss': 'SCSS',
55
+ '.less': 'Less',
56
+ '.html': 'HTML',
57
+ '.md': 'Markdown',
58
+ '.mdx': 'MDX',
59
+ '.py': 'Python',
60
+ '.go': 'Go',
61
+ '.rs': 'Rust',
62
+ '.java': 'Java',
63
+ '.kt': 'Kotlin',
64
+ '.c': 'C',
65
+ '.cpp': 'C++',
66
+ '.cc': 'C++',
67
+ '.h': 'C/C++ Header',
68
+ '.hpp': 'C++ Header',
69
+ '.swift': 'Swift',
70
+ '.rb': 'Ruby',
71
+ '.php': 'PHP',
72
+ '.sql': 'SQL',
73
+ '.sh': 'Shell',
74
+ '.bash': 'Shell',
75
+ '.zsh': 'Shell',
76
+ '.yaml': 'YAML',
77
+ '.yml': 'YAML',
78
+ '.toml': 'TOML',
79
+ '.xml': 'XML',
80
+ '.svg': 'SVG',
81
+ '.vue': 'Vue',
82
+ '.svelte': 'Svelte',
83
+ };
84
+
85
+ // Caps. Wall-clock is enforced by an AbortController-style deadline; the
86
+ // per-file line cap stops giant generated files (e.g. 5 MB package-lock that
87
+ // somehow escapes the ignore list) from blocking the event loop.
88
+ const MAX_DEPTH = 12;
89
+ const MAX_FILES_TO_ANALYZE = 5000;
90
+ const MAX_FILE_SIZE_FOR_LINE_COUNT = 100 * 1024; // 100 KiB
91
+ const DEADLINE_MS = 30_000;
92
+ const TOP_DIRS_LIMIT = 10;
93
+
94
+ /** Count newlines without loading entire file into memory beyond the cap. */
95
+ async function countLines(filePath, sizeHint) {
96
+ if (sizeHint > MAX_FILE_SIZE_FOR_LINE_COUNT) return 0;
97
+ try {
98
+ const buf = await fsp.readFile(filePath);
99
+ if (buf.length === 0) return 0;
100
+ let n = 1;
101
+ for (let i = 0; i < buf.length; i++) {
102
+ if (buf[i] === 0x0a) n++;
103
+ }
104
+ // Don't count a final empty trailing line.
105
+ if (buf[buf.length - 1] === 0x0a) n--;
106
+ return n;
107
+ } catch {
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ function languageKey(ext) {
113
+ if (!ext) return 'other';
114
+ return LANG_LABELS[ext] ? ext.slice(1) : 'other';
115
+ }
116
+
117
+ /**
118
+ * Recursive walk. Mutates `ctx` (single object per top-level call) so we
119
+ * don't allocate a fresh stats frame per directory — keeps the hot path
120
+ * contiguous (CLAUDE.md performance guidance). Stops early when deadline is
121
+ * past or the file ceiling is hit.
122
+ *
123
+ * Returns the per-immediate-subtree { fileCount, totalLines } so the caller
124
+ * (one level up) can attribute it to the top-level directory bucket.
125
+ */
126
+ async function walk(absDir, relDir, depth, ctx) {
127
+ if (ctx.stopped || Date.now() > ctx.deadline) {
128
+ ctx.stopped = true;
129
+ return { fileCount: 0, totalLines: 0 };
130
+ }
131
+ if (depth > MAX_DEPTH) return { fileCount: 0, totalLines: 0 };
132
+
133
+ let entries;
134
+ try {
135
+ entries = await fsp.readdir(absDir, { withFileTypes: true });
136
+ } catch {
137
+ return { fileCount: 0, totalLines: 0 };
138
+ }
139
+
140
+ let subFiles = 0;
141
+ let subLines = 0;
142
+
143
+ for (const entry of entries) {
144
+ if (ctx.stopped) break;
145
+ if (Date.now() > ctx.deadline) {
146
+ ctx.stopped = true;
147
+ break;
148
+ }
149
+
150
+ // Never follow symlinks (security). withFileTypes gives us
151
+ // isSymbolicLink() directly — no extra stat needed.
152
+ if (entry.isSymbolicLink()) continue;
153
+
154
+ const name = entry.name;
155
+
156
+ if (entry.isDirectory()) {
157
+ if (IGNORE_DIRS.has(name)) continue;
158
+ const childAbs = path.join(absDir, name);
159
+ const childRel = relDir ? path.join(relDir, name) : name;
160
+ const result = await walk(childAbs, childRel, depth + 1, ctx);
161
+ subFiles += result.fileCount;
162
+ subLines += result.totalLines;
163
+ continue;
164
+ }
165
+
166
+ if (!entry.isFile()) continue;
167
+ if (IGNORE_FILES.has(name)) continue;
168
+ if (ctx.fileCount >= MAX_FILES_TO_ANALYZE) {
169
+ ctx.stopped = true;
170
+ break;
171
+ }
172
+
173
+ ctx.fileCount++;
174
+ subFiles++;
175
+ ctx.totalFiles++;
176
+
177
+ const ext = path.extname(name).toLowerCase();
178
+ let size = 0;
179
+ let lines = 0;
180
+ try {
181
+ const st = await fsp.stat(path.join(absDir, name));
182
+ size = st.size;
183
+ } catch {
184
+ // unreadable — count the file but skip lines
185
+ }
186
+
187
+ // Only line-count "interesting" files. JSON / SVG / XML / YAML are noisy
188
+ // and don't really represent "code"; skipping them keeps the LOC number
189
+ // closer to what a developer expects.
190
+ if (LANG_LABELS[ext] && ext !== '.svg' && ext !== '.xml' && ext !== '.json') {
191
+ lines = await countLines(path.join(absDir, name), size);
192
+ }
193
+ subLines += lines;
194
+ ctx.totalLines += lines;
195
+
196
+ // Language breakdown bucket.
197
+ const key = languageKey(ext);
198
+ const langBucket = ctx.languages[key] ?? (ctx.languages[key] = { files: 0, lines: 0 });
199
+ langBucket.files++;
200
+ langBucket.lines += lines;
201
+ }
202
+
203
+ // Top-dir attribution happens in analyze() based on the return value, not
204
+ // here — avoids double-pushing when walk recurses into nested dirs.
205
+ return { fileCount: subFiles, totalLines: subLines };
206
+ }
207
+
208
+ /**
209
+ * repo:analyze entry — runs the walk, then asks git.cjs for status (cached,
210
+ * so this is cheap when called repeatedly). On any path-validation or fs
211
+ * failure returns a `{ ok:false, error }` shape so the renderer can decide
212
+ * whether to show an error state or just empty stats.
213
+ */
214
+ async function analyze(cwd) {
215
+ let realCwd;
216
+ try {
217
+ realCwd = validatePath(cwd);
218
+ } catch (e) {
219
+ return { ok: false, error: (e && e.message) || 'invalid cwd' };
220
+ }
221
+
222
+ const startMs = Date.now();
223
+ const ctx = {
224
+ deadline: startMs + DEADLINE_MS,
225
+ stopped: false,
226
+ fileCount: 0,
227
+ totalFiles: 0,
228
+ totalLines: 0,
229
+ languages: Object.create(null),
230
+ topDirs: [],
231
+ };
232
+
233
+ // Walk the top-level dir manually (we want depth-1 directories listed in
234
+ // topDirs but not "." itself).
235
+ let entries;
236
+ try {
237
+ entries = await fsp.readdir(realCwd, { withFileTypes: true });
238
+ } catch (e) {
239
+ return { ok: false, error: (e && e.message) || 'readdir failed' };
240
+ }
241
+
242
+ // First pass: count files directly in the root, group root-level loose
243
+ // files under a synthetic "(root)" bucket so they're visible in the treemap.
244
+ let rootLooseFiles = 0;
245
+ let rootLooseLines = 0;
246
+
247
+ // Process directories at depth 1, files at depth 0 (root). We follow the
248
+ // pattern: lstat-equivalent (entry.isSymbolicLink()) → no follow.
249
+ for (const entry of entries) {
250
+ if (ctx.stopped) break;
251
+ if (Date.now() > ctx.deadline) { ctx.stopped = true; break; }
252
+ if (entry.isSymbolicLink()) continue;
253
+ const name = entry.name;
254
+
255
+ if (entry.isDirectory()) {
256
+ if (IGNORE_DIRS.has(name)) continue;
257
+ const result = await walk(path.join(realCwd, name), name, 1, ctx);
258
+ ctx.topDirs.push({ path: name, fileCount: result.fileCount, totalLines: result.totalLines });
259
+ continue;
260
+ }
261
+
262
+ if (!entry.isFile()) continue;
263
+ if (IGNORE_FILES.has(name)) continue;
264
+ if (ctx.fileCount >= MAX_FILES_TO_ANALYZE) { ctx.stopped = true; break; }
265
+
266
+ ctx.fileCount++;
267
+ ctx.totalFiles++;
268
+ rootLooseFiles++;
269
+
270
+ const ext = path.extname(name).toLowerCase();
271
+ let size = 0;
272
+ let lines = 0;
273
+ try {
274
+ const st = await fsp.stat(path.join(realCwd, name));
275
+ size = st.size;
276
+ } catch { /* */ }
277
+ if (LANG_LABELS[ext] && ext !== '.svg' && ext !== '.xml' && ext !== '.json') {
278
+ lines = await countLines(path.join(realCwd, name), size);
279
+ }
280
+ rootLooseLines += lines;
281
+ ctx.totalLines += lines;
282
+ const key = languageKey(ext);
283
+ const langBucket = ctx.languages[key] ?? (ctx.languages[key] = { files: 0, lines: 0 });
284
+ langBucket.files++;
285
+ langBucket.lines += lines;
286
+ }
287
+
288
+ if (rootLooseFiles > 0) {
289
+ ctx.topDirs.push({ path: '(root)', fileCount: rootLooseFiles, totalLines: rootLooseLines });
290
+ }
291
+
292
+ // Sort top dirs by file count desc, then trim. Tie-break by name for stability.
293
+ ctx.topDirs.sort((a, b) => {
294
+ if (b.fileCount !== a.fileCount) return b.fileCount - a.fileCount;
295
+ return a.path.localeCompare(b.path);
296
+ });
297
+ const topDirectories = ctx.topDirs.slice(0, TOP_DIRS_LIMIT);
298
+
299
+ // Materialize the languages map sorted by file count desc.
300
+ const languageBreakdown = Object.create(null);
301
+ const langEntries = Object.entries(ctx.languages).sort((a, b) => b[1].files - a[1].files);
302
+ for (const [k, v] of langEntries) languageBreakdown[k] = v;
303
+
304
+ // Git status — best-effort. Failure (not a git repo / git missing / timeout)
305
+ // returns null which we surface as { uncommitted: 0, branch: null }.
306
+ let gitStatus = { uncommitted: 0, branch: null };
307
+ try {
308
+ const s = await gitMod.getStatus(realCwd);
309
+ if (s) {
310
+ gitStatus = {
311
+ uncommitted: s.uncommittedCount || 0,
312
+ branch: s.branch || null,
313
+ };
314
+ }
315
+ } catch { /* gitMod swallows errors but be defensive */ }
316
+
317
+ return {
318
+ ok: true,
319
+ cwd: realCwd,
320
+ totalFiles: ctx.totalFiles,
321
+ totalLines: ctx.totalLines,
322
+ languageBreakdown,
323
+ topDirectories,
324
+ gitStatus,
325
+ truncated: ctx.stopped,
326
+ durationMs: Date.now() - startMs,
327
+ };
328
+ }
329
+
330
+ function register(ipcMain) {
331
+ ipcMain.handle('repo:analyze', async (_e, payload) => {
332
+ const parsed = schemas.repoAnalyze.safeParse(payload);
333
+ if (!parsed.success) {
334
+ return { ok: false, error: 'invalid payload' };
335
+ }
336
+ return analyze(parsed.data.cwd);
337
+ });
338
+ }
339
+
340
+ module.exports = {
341
+ register,
342
+ analyze,
343
+ // Exported for tests.
344
+ LANG_LABELS,
345
+ IGNORE_DIRS,
346
+ };