codesynapt 0.0.0

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +686 -0
  3. package/LICENSES.md +141 -0
  4. package/README.md +331 -0
  5. package/electron/main.cjs +2849 -0
  6. package/electron/plugin-loader.cjs +184 -0
  7. package/electron/preload.cjs +108 -0
  8. package/package.json +216 -0
  9. package/packages/core/bin/codesynapt-mcp.cjs +611 -0
  10. package/packages/core/bin/codesynapt.cjs +1933 -0
  11. package/packages/core/legacy.js +300 -0
  12. package/packages/core/lib/control-server.cjs +1539 -0
  13. package/packages/core/lib/embedding.cjs +89 -0
  14. package/packages/core/lib/logger.cjs +63 -0
  15. package/packages/core/lib/search-cache.cjs +140 -0
  16. package/packages/core/lib/search-worker.cjs +255 -0
  17. package/packages/core/lib/search.cjs +211 -0
  18. package/packages/core/lib/symbol-graph.cjs +402 -0
  19. package/packages/core/lib/symbol-parser-js.cjs +542 -0
  20. package/packages/core/lib/symbol-parser-misc.cjs +394 -0
  21. package/packages/core/lib/symbol-parser-py.cjs +215 -0
  22. package/packages/core/lib/symbol-parser-treesitter.cjs +658 -0
  23. package/packages/core/lib/symbol-parser-tsc.cjs +332 -0
  24. package/packages/core/monorepo.js +310 -0
  25. package/packages/core/parser.js +2234 -0
  26. package/packages/core/scanner.js +623 -0
  27. package/plugin-api/LICENSE +21 -0
  28. package/plugin-api/README.md +114 -0
  29. package/plugin-api/docs/01-getting-started.md +197 -0
  30. package/plugin-api/docs/02-concepts.md +269 -0
  31. package/plugin-api/docs/api-reference.md +463 -0
  32. package/plugin-api/docs/troubleshooting.md +332 -0
  33. package/plugin-api/docs/types/exporter.md +377 -0
  34. package/plugin-api/docs/types/theme.md +312 -0
  35. package/plugin-api/examples/hello-world-plugin/README.md +70 -0
  36. package/plugin-api/examples/hello-world-plugin/main.js +36 -0
  37. package/plugin-api/examples/hello-world-plugin/manifest.json +12 -0
  38. package/plugin-api/examples/mermaid-exporter/README.md +125 -0
  39. package/plugin-api/examples/mermaid-exporter/main.js +58 -0
  40. package/plugin-api/examples/mermaid-exporter/manifest.json +12 -0
  41. package/plugin-api/examples/rust-parser/README.md +71 -0
  42. package/plugin-api/examples/rust-parser/main.js +123 -0
  43. package/plugin-api/examples/rust-parser/manifest.json +12 -0
  44. package/plugin-api/examples/sunset-theme/README.md +95 -0
  45. package/plugin-api/examples/sunset-theme/manifest.json +12 -0
  46. package/plugin-api/examples/sunset-theme/theme.css +31 -0
  47. package/plugin-api/package.json +20 -0
  48. package/plugin-api/types.d.ts +395 -0
  49. package/public/app.js +6837 -0
  50. package/public/backend.js +285 -0
  51. package/public/index.html +647 -0
  52. package/public/plugin-host.js +321 -0
  53. package/public/style.css +4359 -0
  54. package/public/vendor/three.module.js +53044 -0
  55. package/scripts/competitor-watch.mjs +144 -0
  56. package/scripts/copy-vendor.js +21 -0
  57. package/scripts/download-bundled-node.cjs +53 -0
  58. package/scripts/fuses-after-pack.cjs +34 -0
  59. package/scripts/license-check.js +119 -0
  60. package/scripts/perf-test.js +200 -0
  61. package/server.js +132 -0
@@ -0,0 +1,2849 @@
1
+ // Electron main process — desktop app shell for CodeSynapt
2
+ const { app, BrowserWindow, ipcMain, dialog, Menu, shell } = require('electron')
3
+ const path = require('path')
4
+ const fs = require('fs')
5
+ const http = require('http')
6
+ const os = require('os')
7
+ const { execFile } = require('child_process')
8
+ const { promisify } = require('util')
9
+ const pExecFile = promisify(execFile)
10
+
11
+ // ─── Persistent state (window bounds, recent folders) ──────────
12
+ const STORE_PATH = path.join(app.getPath('userData'), 'state.json')
13
+ let store = {
14
+ windowBounds: null,
15
+ lastFolder: null,
16
+ recentFolders: [], // auto-tracked, capped at 8
17
+ pinnedProjects: [], // user-pinned: [{ path, name, pinnedAt, color? }]
18
+ }
19
+ try {
20
+ store = { ...store, ...JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')) }
21
+ } catch {}
22
+ function saveStore() {
23
+ try { fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2)) } catch {}
24
+ }
25
+ function addRecent(folder) {
26
+ store.recentFolders = [folder, ...store.recentFolders.filter((f) => f !== folder)]
27
+ .slice(0, 8)
28
+ store.lastFolder = folder
29
+ saveStore()
30
+ rebuildMenu()
31
+ }
32
+
33
+ // ─── Scanner (loaded dynamically because it's ESM) ─────────────
34
+ let Scanner = null
35
+ let scanner = null
36
+ let mainWindow = null
37
+ let currentRoot = null
38
+
39
+ async function loadScannerModule() {
40
+ if (Scanner) return Scanner
41
+ const mod = await import('../packages/core/scanner.js')
42
+ Scanner = mod.Scanner
43
+ return Scanner
44
+ }
45
+
46
+ // Symbol-mode graph (codegraph-equivalent layer). Lazy: built on the
47
+ // first /symbol/* request after a project loads. Reset on every
48
+ // project swap so it never returns stale symbols from a prior repo.
49
+ const { SymbolGraph, registerParser } = require('../packages/core/lib/symbol-graph.cjs')
50
+ const jsSymbolParser = require('../packages/core/lib/symbol-parser-js.cjs')
51
+ const pySymbolParser = require('../packages/core/lib/symbol-parser-py.cjs')
52
+ const miscSymbolParsers = require('../packages/core/lib/symbol-parser-misc.cjs')
53
+ // Stage 3 — tree-sitter exact parsers. Default ON; CS_SYMBOL_PARSER=regex
54
+ // falls back to the regex/Babel parsers above for comparison or when
55
+ // the WASM grammars aren't shipped (e.g. a stripped portable build).
56
+ let _tsParserModule = null
57
+ try { _tsParserModule = require('../packages/core/lib/symbol-parser-treesitter.cjs') } catch {}
58
+ // Stage 4 — TypeScript compiler API integration. Opt-in via
59
+ // CS_SYMBOL_PARSER=tsc, because building the TS Program loads every
60
+ // file in the repo and is slower than babel for medium projects.
61
+ // Worth it for accuracy on heavy-TS codebases where overloaded method
62
+ // names (`save()` on many classes) need real type-checker resolution.
63
+ let _tscParserModule = null
64
+ try { _tscParserModule = require('../packages/core/lib/symbol-parser-tsc.cjs') } catch {}
65
+ const SYMBOL_PARSER_MODE = process.env.CS_SYMBOL_PARSER || 'treesitter'
66
+
67
+ function registerSymbolParsers() {
68
+ // Always register the Stage-1/2 parsers as the fallback set.
69
+ registerParser(['js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx'], jsSymbolParser)
70
+ registerParser(['py'], pySymbolParser)
71
+ registerParser(['go'], miscSymbolParsers.go)
72
+ registerParser(['rs'], miscSymbolParsers.rust)
73
+ registerParser(['java', 'kt'], miscSymbolParsers.javaKt)
74
+ registerParser(['swift'], miscSymbolParsers.swift)
75
+ if (SYMBOL_PARSER_MODE !== 'treesitter' || !_tsParserModule) return
76
+ // Override per-extension with tree-sitter parsers where a grammar
77
+ // is available. The fallback set above still answers for languages
78
+ // without a shipped wasm.
79
+ try {
80
+ for (const ext of _tsParserModule.availableExtensions()) {
81
+ // TypeScript / TSX have no dedicated grammar in our bundled
82
+ // tree-sitter-wasms — the JS grammar is missing `interface`,
83
+ // type aliases, generics, etc. Keep babel for those.
84
+ if (ext === 'ts' || ext === 'tsx') continue
85
+ const tsP = _tsParserModule.makeParser(ext)
86
+ if (tsP) registerParser([ext], tsP)
87
+ }
88
+ } catch (e) {
89
+ console.error('[symbol] tree-sitter init failed, falling back to regex:', e.message)
90
+ }
91
+ // Stage 4 override — TypeScript compiler API for .ts/.tsx/.js/.jsx
92
+ // when the user opts in. Provides true type-checker-resolved call
93
+ // edges (no more random matches across same-named methods).
94
+ if (SYMBOL_PARSER_MODE === 'tsc' && _tscParserModule?.isAvailable?.()) {
95
+ const tscP = _tscParserModule.makeParser()
96
+ for (const ext of ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']) {
97
+ registerParser([ext], tscP)
98
+ }
99
+ }
100
+ }
101
+ registerSymbolParsers()
102
+ let symbolGraph = null // SymbolGraph instance; rebuilt per project
103
+ let _symbolBuilding = null // in-flight build promise (avoid double work)
104
+
105
+ // Path looks like a test file? Used by explore() ranking to push real
106
+ // implementation symbols above test fixtures with similar names.
107
+ // Heavier explore-only auxiliary-path filter than `isTestPath` —
108
+ // covers vendored bundles inside src/ (`compiled/`, `vendor/`),
109
+ // example apps, and tooling code. Match is "any segment is one of
110
+ // these". Distinct from isAuxPath in symbol-graph (that one drives
111
+ // resolver buckets); here it drives explore ranking.
112
+ const EXPLORE_AUX_SEGMENTS = new Set([
113
+ 'compiled', 'vendored', 'vendor', '_compiled',
114
+ 'examples', 'example', 'samples', 'sample', 'demo', 'demos',
115
+ 'scripts', 'script', 'tools', 'tool',
116
+ 'build', 'dist', 'out', 'bin',
117
+ 'fixtures', 'fixture',
118
+ // Additional rarely-production segments — common in OSS layouts
119
+ // but never the answer to "how does X actually work".
120
+ 'mocks', 'mock', '__mocks__',
121
+ 'stubs', 'stub',
122
+ 'storybook', '.storybook', 'stories',
123
+ 'docs-src', 'documentation',
124
+ ])
125
+ function isAuxExplorePath(filePath) {
126
+ if (!filePath) return false
127
+ const parts = filePath.toLowerCase().replace(/\\/g, '/').split('/')
128
+ return parts.some((p) => EXPLORE_AUX_SEGMENTS.has(p))
129
+ }
130
+
131
+ function isTestPath(filePath) {
132
+ if (!filePath) return false
133
+ const p = filePath.toLowerCase().replace(/\\/g, '/')
134
+ // path segments: …/tests/, …/test/, …/__tests__/, …/spec/,
135
+ // …/e2e/, …/integration/, …/bench/ (perf test)
136
+ if (/\/(tests?|__tests__|spec|specs|e2e|integration|integration[_-]tests?|fixtures?|bench(es|marks?)?)\//.test('/' + p + '/')) return true
137
+ // suffixes: foo_test.go, foo.test.ts, FooTests.swift, FooTest.java,
138
+ // foo.bench.ts, foo_bench.go, foo.spec.tsx
139
+ if (/(?:_test|\.test|\.spec|\.bench|_bench|\.e2e)\.[a-z]+$/.test(p)) return true
140
+ if (/tests?\.(swift|kt|java)$/.test(p)) return true
141
+ return false
142
+ }
143
+
144
+ // Stopwords stripped from the explore query before keyword matching.
145
+ const EXPLORE_STOPWORDS = new Set([
146
+ 'a','an','and','are','as','at','be','by','do','does','for','from','how','in',
147
+ 'into','is','it','its','of','on','or','the','their','this','to','using','what',
148
+ 'when','where','which','who','why','will','with',
149
+ ])
150
+
151
+ // Break a token at camelCase / PascalCase / digit boundaries so query
152
+ // "getUserName" also tries [get, user, name]. Acronym-aware: keeps
153
+ // uppercase runs together when followed by another word (HTTPRequest
154
+ // → [HTTP, Request], not [H, T, T, P, Request]). Each alternative
155
+ // covers a specific shape and the order matters:
156
+ // 1. [A-Z]+(?=[A-Z][a-z]) — leading acronym before a word
157
+ // ("HTTP" in "HTTPRequest")
158
+ // 2. [A-Z]?[a-z]+ — normal camel word
159
+ // ("Request" / "get" / "Name")
160
+ // 3. [A-Z]+ — trailing acronym
161
+ // ("HTML" at end of "URL2HTML")
162
+ // 4. [0-9]+ — digit group
163
+ function splitCamelCase(w) {
164
+ const parts = w.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g) || []
165
+ return parts.map((s) => s.toLowerCase())
166
+ }
167
+
168
+ // Simplified Porter-style stemmer. Two phases:
169
+ // Step 2 — derivational suffix MAP (rewrite, not strip)
170
+ // "authentication" → "authentic + ate" → "authenticate"
171
+ // Step 4 — terminal suffix STRIP if stem stays ≥ 4 chars
172
+ // "processing"/"processed"/"processor" → "process"
173
+ //
174
+ // Mapping suffixes first preserves the "verb form" so different
175
+ // noun/adjective inflections normalize back to it. Plain strip-only
176
+ // (the previous 12-suffix version) wasn't enough — "authentication"
177
+ // would just lose "ation" and become "authentic", which doesn't
178
+ // substring-match "authenticate".
179
+ //
180
+ // Stem length lower bound (>=4) guards against over-stripping
181
+ // "rate" → "r" etc. We deliberately don't strip bare "s" or "es":
182
+ // risks of mangling "process"/"business"/"axis" outweigh the wins.
183
+ const STEM_STEP2 = [
184
+ ['ization', 'ize'], ['ational', 'ate'], ['fulness', 'ful'],
185
+ ['ousness', 'ous'], ['iveness', 'ive'], ['tional', 'tion'],
186
+ ['ation', 'ate'], ['ator', 'ate'], ['ative', 'ate'],
187
+ ['izer', 'ize'], ['icate', 'ic'], ['alize', 'al'],
188
+ ['ical', 'ic'],
189
+ ]
190
+ const STEM_STEP4 = [
191
+ 'ement', 'ements', 'ance', 'ances', 'ence', 'ences',
192
+ 'ment', 'ments', 'tion', 'tions', 'sion', 'sions',
193
+ 'able', 'ables', 'ible', 'ibles', 'ism', 'isms',
194
+ 'ness', 'nesses', 'ful', 'fully', 'ous', 'ously',
195
+ 'ive', 'ives', 'ize', 'izes', 'ized', 'izing',
196
+ 'ing', 'ed', 'ly', 'er', 'or',
197
+ ]
198
+ function stem(w) {
199
+ if (w.length < 5) return w
200
+ for (const [from, to] of STEM_STEP2) {
201
+ if (w.endsWith(from) && w.length - from.length >= 3) {
202
+ return w.slice(0, -from.length) + to
203
+ }
204
+ }
205
+ for (const suf of STEM_STEP4) {
206
+ if (w.endsWith(suf) && w.length - suf.length >= 4) {
207
+ return w.slice(0, -suf.length)
208
+ }
209
+ }
210
+ return w
211
+ }
212
+
213
+ // Deprecated / TODO-remove markers. SymbolGraph.build sets
214
+ // `node.deprecated` from the 5 lines above each symbol (handles
215
+ // babel's quirky export-wrapper leading-comment attachment). We
216
+ // also fall back to scanning doc/signature for parsers that do
217
+ // surface comments correctly (regex/tree-sitter parsers).
218
+ function isDeprecatedSymbol(node) {
219
+ if (node.deprecated === true) return true
220
+ const d = ((node.doc || '') + ' ' + (node.signature || '')).toLowerCase()
221
+ if (!d) return false
222
+ return /(\s|^)@?deprecated\b|todo\s*[:_-]?\s*remove|fixme\s*[:_-]?\s*remove/.test(d)
223
+ }
224
+
225
+ // Public entry detection — symbols that an external caller reaches
226
+ // (a `main`, a route handler, a CLI bin script, an SDK default
227
+ // export) often have in-degree zero in *our* graph because the
228
+ // caller lives outside the codebase (OS shell, HTTP request, npm
229
+ // consumer). Without flagging these, the orphan damping treats
230
+ // them as dead code. We flag only when the symbol is exported AND
231
+ // either the name or the containing file matches an entry pattern
232
+ // — both signals so we don't sweep every exported util into the
233
+ // "entry" bucket.
234
+ const ENTRY_NAMES = new Set([
235
+ 'main', 'run', 'start', 'init', 'handler', 'bootstrap',
236
+ 'setup', 'listen', 'serve', 'cli', 'app', 'default',
237
+ ])
238
+ const ENTRY_FILE_RE = /(?:^|\/)(?:main|index|entry|server|app|cli|bin|run)(?:\.[a-z]+)?$/i
239
+ function isPublicEntry(node) {
240
+ if (!node.exported) return false
241
+ const name = (node.name || '').toLowerCase()
242
+ if (ENTRY_NAMES.has(name)) return true
243
+ if (ENTRY_FILE_RE.test(node.file || '')) return true
244
+ return false
245
+ }
246
+
247
+
248
+ // ─── mode 3 (classify) — ranking-free response ─────────────────
249
+ // Returns symbols grouped by lifecycle classification, no score
250
+ // sort. AI/user picks the group it cares about (active for "what
251
+ // runs in production", deprecated/legacy/orphan for "what's safe
252
+ // to delete"). Same candidate selection as the ranked mode 2 —
253
+ // keyword expansion + optional semantic — but the only ordering
254
+ // inside each group is in-degree DESC (graph signal, not a
255
+ // computed score).
256
+ //
257
+ // Why a separate mode 3 instead of just sorting mode 2's output?
258
+ // Mode 2 collapses everything into a single ranked list, so a
259
+ // deprecated symbol with a perfect keyword match can still beat
260
+ // the live implementation just by score arithmetic. Mode 3 makes
261
+ // the grouping the contract — `groups.active[0]` is always the
262
+ // live answer even if a deprecated dup has the same name.
263
+ async function buildClassifyResponse(g, query, budget = 8000) {
264
+ const q = (query || '').toLowerCase()
265
+ const rawTokens = q.split(/[^\p{L}\p{N}_]+/u).filter(Boolean)
266
+ const expanded = new Set(rawTokens)
267
+ for (const t of rawTokens) {
268
+ for (const p of splitCamelCase(t)) if (p.length > 1) expanded.add(p)
269
+ const s = stem(t)
270
+ if (s !== t && s.length > 2) expanded.add(s)
271
+ }
272
+ const keywords = [...expanded].filter((t) => t.length > 1 && !EXPLORE_STOPWORDS.has(t))
273
+ if (!keywords.length) {
274
+ return { query, mode: 'classify', keywords: [], groups: {}, counts: {}, snippets: [], note: 'no usable keywords' }
275
+ }
276
+
277
+ // Candidate selection — same byName / byFile index walk mode 2
278
+ // does. No scoring; we just collect every symbol whose name or
279
+ // file path contains at least one keyword as a substring.
280
+ const candidates = new Set()
281
+ for (const k of keywords) {
282
+ for (const [n, ids] of g.byName) {
283
+ if (n.includes(k)) for (const id of ids) candidates.add(id)
284
+ }
285
+ for (const [f, ids] of g.byFile) {
286
+ if (f.toLowerCase().includes(k)) for (const id of ids) candidates.add(id)
287
+ }
288
+ }
289
+
290
+ // Optional semantic candidate enrichment — when embeddings are
291
+ // ready, pull the top 30 symbols by cosine similarity to the
292
+ // raw query and union them in. Lets a query like "auth" find
293
+ // `login` / `signIn` even when the keyword set never hits.
294
+ let semHits = new Map() // id → similarity
295
+ if (g._embedded && query) {
296
+ try {
297
+ const embedding = require('../packages/core/lib/embedding.cjs')
298
+ const qVec = await embedding.embed(query)
299
+ if (qVec) {
300
+ const sims = []
301
+ for (const node of g.nodes.values()) {
302
+ if (!node._embedding) continue
303
+ const sim = embedding.cosineSim(qVec, node._embedding)
304
+ if (sim > 0.3) sims.push({ id: node.id, sim })
305
+ }
306
+ sims.sort((a, b) => b.sim - a.sim)
307
+ for (const { id, sim } of sims.slice(0, 30)) {
308
+ candidates.add(id)
309
+ semHits.set(id, sim)
310
+ }
311
+ }
312
+ } catch {}
313
+ }
314
+
315
+ // Classify every candidate. Two cross-cutting groups (exact_match
316
+ // and semantic) take precedence over the lifecycle classification
317
+ // and are *exclusive* — a symbol that lives in exact_match is
318
+ // intentionally NOT also listed in active/orphan/etc, so the AI
319
+ // doesn't have to dedupe. The lifecycle bucket is preserved as a
320
+ // field on each entry so the consumer still sees "this exact-match
321
+ // is actually deprecated".
322
+ const groups = {
323
+ exact_match: [], semantic: [],
324
+ active: [], entry: [], deprecated: [], legacy: [],
325
+ test: [], aux: [], orphan: [], normal: [],
326
+ }
327
+ const keywordSet = new Set(keywords)
328
+ for (const id of candidates) {
329
+ const node = g.nodes.get(id)
330
+ if (!node) continue
331
+ const inD = g.inAdj.get(id)?.size || 0
332
+ const ouD = g.outAdj.get(id)?.size || 0
333
+ const mtime = node.mtimeMs || 0
334
+ const ageMs = mtime ? (Date.now() - mtime) : 0
335
+ const ONE_YEAR = 365 * 86400_000
336
+
337
+ let cls
338
+ if (isDeprecatedSymbol(node)) cls = 'deprecated'
339
+ else if (isTestPath(node.file)
340
+ || /^test[A-Z_]/.test(node.name || '')
341
+ || /(_test$|spec$|Spec$)/.test(node.name || '')) cls = 'test'
342
+ else if (isAuxExplorePath(node.file)) cls = 'aux'
343
+ else if (isPublicEntry(node)) cls = 'entry'
344
+ else if (inD === 0 && ouD === 0) cls = 'orphan'
345
+ else if (ageMs > ONE_YEAR && inD < 2) cls = 'legacy'
346
+ else if (inD >= 3) cls = 'active'
347
+ else cls = 'normal'
348
+
349
+ const reachable = g._reachable ? g._reachable.has(id) : null
350
+ const semOnly = !rawHasSubstring(node, keywords)
351
+ const nameLower = (node.name || '').toLowerCase()
352
+ const isExactName = keywordSet.has(nameLower)
353
+
354
+ const entry = {
355
+ qualifiedName: node.qualifiedName || node.name,
356
+ name: node.name, kind: node.kind,
357
+ file: node.file, startLine: node.startLine, endLine: node.endLine,
358
+ inDegree: inD, outDegree: ouD,
359
+ ageDays: mtime ? Math.floor(ageMs / 86400_000) : null,
360
+ reachable,
361
+ semSim: semHits.get(id) ?? null,
362
+ classification: cls, // lifecycle preserved even when bucketed elsewhere
363
+ semanticOnly: semOnly,
364
+ }
365
+ // Routing order: exact_match wins over everything (user typed
366
+ // the exact name — surface that even if the symbol is orphan).
367
+ // Then semantic (keyword 0 hit, only the embedding pulled it
368
+ // in). Then lifecycle classification.
369
+ if (isExactName) groups.exact_match.push(entry)
370
+ else if (semOnly) groups.semantic.push(entry)
371
+ else groups[cls].push(entry)
372
+ }
373
+
374
+ // Sort each group: in-degree DESC, then semSim DESC (so semantic
375
+ // candidates surface inside their group when present). Cap per
376
+ // group to keep responses manageable.
377
+ const PER_GROUP_CAP = 8
378
+ for (const g_name of Object.keys(groups)) {
379
+ groups[g_name].sort((a, b) => (b.inDegree - a.inDegree) || ((b.semSim || 0) - (a.semSim || 0)))
380
+ groups[g_name] = groups[g_name].slice(0, PER_GROUP_CAP)
381
+ }
382
+
383
+ // Snippets — source bodies for the top members of the most
384
+ // informative groups. `active` first (real implementation),
385
+ // then `entry`, then `normal`. Skips test/aux/orphan/deprecated
386
+ // unless those are the only groups with hits — saves token
387
+ // budget for code AI actually needs to read.
388
+ const snippets = []
389
+ const MAX_LINES_PER_SNIPPET = 40
390
+ let used = 0
391
+ const SNIPPET_ORDER = ['exact_match', 'active', 'entry', 'normal', 'semantic', 'legacy', 'deprecated', 'test', 'orphan', 'aux']
392
+ outer: for (const groupName of SNIPPET_ORDER) {
393
+ const members = groups[groupName]
394
+ if (!members.length) continue
395
+ const perGroupBudget = groupName === 'exact_match' ? 5
396
+ : (groupName === 'active' || groupName === 'entry') ? 3
397
+ : 1
398
+ for (const m of members.slice(0, perGroupBudget)) {
399
+ if (used >= budget) break outer
400
+ try {
401
+ const filePath = path.join(currentRoot, m.file)
402
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n')
403
+ const end = Math.min(m.endLine, m.startLine + MAX_LINES_PER_SNIPPET - 1)
404
+ const source = lines.slice(m.startLine - 1, end).join('\n')
405
+ const cost = Math.ceil(source.length / 4)
406
+ if (used + cost > budget && snippets.length > 0) break outer
407
+ snippets.push({
408
+ group: groupName, file: m.file, line: m.startLine,
409
+ name: m.name, kind: m.kind, source,
410
+ })
411
+ used += cost
412
+ } catch {}
413
+ }
414
+ }
415
+
416
+ const counts = {}
417
+ for (const [k, v] of Object.entries(groups)) if (v.length) counts[k] = v.length
418
+
419
+ return {
420
+ query, mode: 'classify', keywords,
421
+ groups, counts, snippets,
422
+ embeddingReady: !!g._embedded,
423
+ }
424
+ }
425
+
426
+ // Whether any keyword raw-substring matches the symbol — used to
427
+ // flag entries that came in via semantic similarity only.
428
+ function rawHasSubstring(node, keywords) {
429
+ const name = (node.name || '').toLowerCase()
430
+ const qn = (node.qualifiedName || '').toLowerCase()
431
+ const file = (node.file || '').toLowerCase()
432
+ for (const k of keywords) {
433
+ if (name.includes(k) || qn.includes(k) || file.includes(k)) return true
434
+ }
435
+ return false
436
+ }
437
+
438
+ // Legacy audit module — lazy-loaded ESM. Cached by snapshotVersion.
439
+ let _legacyAudit = null
440
+ let _legacyCache = { version: -1, data: null }
441
+ async function loadLegacyAudit() {
442
+ if (_legacyAudit) return _legacyAudit
443
+ const mod = await import('../packages/core/legacy.js')
444
+ _legacyAudit = mod.auditLegacy
445
+ return _legacyAudit
446
+ }
447
+ async function buildLegacyCached() {
448
+ if (!scanner) return null
449
+ const v = scanner.snapshotVersion || 0
450
+ if (_legacyCache.version === v && _legacyCache.data) return _legacyCache.data
451
+ const fn = await loadLegacyAudit()
452
+ const data = fn(scanner)
453
+ if (scanner.snapshotVersion === v) _legacyCache = { version: v, data }
454
+ return data
455
+ }
456
+
457
+ async function startScanner(root) {
458
+ // Await teardown: scanner.stop() returns chokidar's close() promise. Not
459
+ // awaiting leaks the old watcher's fs handles when we immediately attach a
460
+ // new one (worst on Windows). clearParserCaches also runs inside stop().
461
+ if (scanner) { try { await scanner.stop() } catch {} ; scanner = null }
462
+ await loadScannerModule()
463
+
464
+ if (!fs.existsSync(root)) {
465
+ mainWindow?.webContents.send('error', { message: `Path does not exist: ${root}` })
466
+ return
467
+ }
468
+ // If a file was dropped instead of a folder, automatically use its
469
+ // parent directory. This is a common interaction — users drag a
470
+ // representative source file rather than a whole folder.
471
+ const stat = fs.statSync(root)
472
+ if (!stat.isDirectory()) {
473
+ if (stat.isFile()) {
474
+ const parent = path.dirname(root)
475
+ mainWindow?.webContents.send('error', {
476
+ message: `Opened parent folder: ${path.basename(parent)}/`,
477
+ })
478
+ root = parent
479
+ } else {
480
+ mainWindow?.webContents.send('error', { message: `Not a directory: ${root}` })
481
+ return
482
+ }
483
+ }
484
+
485
+ // Stop any in-flight scanner cleanly before installing a new one
486
+ currentRoot = root
487
+ timelineCache = { root: null, data: null, building: false } // invalidate
488
+ // Drop every scanner-version-keyed cache so the next /summary, /packages,
489
+ // /legacy call recomputes against the freshly-loaded project instead of
490
+ // returning stale data from the previous project.
491
+ _summaryCache = { version: -1, data: null }
492
+ _packagesCache = { version: -1, data: null }
493
+ _legacyCache = { version: -1, data: null }
494
+ // Symbol-mode graph belongs to the previous project — drop it. The
495
+ // next /symbol/* request will rebuild against the new file set.
496
+ symbolGraph = null
497
+ _symbolBuilding = null
498
+ // tsc Program cache must also be cleared so we don't keep the old
499
+ // project's SourceFiles around.
500
+ try { _tscParserModule?.clearAllPrograms?.() } catch {}
501
+ migrateLegacyHistoryDir(root)
502
+ addRecent(root)
503
+ scanner = new Scanner(root)
504
+
505
+ scanner.on('snapshot', (data) => {
506
+ mainWindow?.webContents.send('snapshot', { ...data, root })
507
+ })
508
+ scanner.on('stats', (s) => {
509
+ mainWindow?.webContents.send('stats', s)
510
+ })
511
+ scanner.on('scan-progress', (p) => {
512
+ mainWindow?.webContents.send('scan-progress', p)
513
+ })
514
+ scanner.on('file-changed', ({ id, absPath }) => {
515
+ try {
516
+ const stat = fs.statSync(absPath)
517
+ if (stat.size > 2_000_000) return
518
+ const content = fs.readFileSync(absPath, 'utf8')
519
+ snapshotHistory(currentRoot, id, content)
520
+ trackChange(id, content)
521
+ } catch {}
522
+ })
523
+
524
+ mainWindow?.webContents.send('folder-loaded', { root })
525
+ startTraceSession()
526
+ try {
527
+ scanner.start()
528
+ } catch (err) {
529
+ mainWindow?.webContents.send('error', { message: `Failed to start scanner: ${err.message}` })
530
+ scanner = null
531
+ currentRoot = null
532
+ }
533
+ }
534
+
535
+ function stopScanner() {
536
+ if (scanner) { Promise.resolve(scanner.stop()).catch(() => {}); scanner = null }
537
+ closeTraceWriteStream()
538
+ currentRoot = null
539
+ }
540
+
541
+ // ─── Window ─────────────────────────────────────────────────────
542
+ function createWindow() {
543
+ const bounds = store.windowBounds || { width: 1280, height: 820 }
544
+ mainWindow = new BrowserWindow({
545
+ ...bounds,
546
+ minWidth: 720,
547
+ minHeight: 480,
548
+ backgroundColor: '#07090F',
549
+ titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
550
+ title: 'CodeSynapt',
551
+ webPreferences: {
552
+ preload: path.join(__dirname, 'preload.cjs'),
553
+ contextIsolation: true,
554
+ nodeIntegration: false,
555
+ // Sandbox isolates the renderer from Node APIs entirely. Preload uses
556
+ // only contextBridge + ipcRenderer (no fs/path/etc), so sandbox:true
557
+ // is compatible. Reduces blast radius of any renderer-side compromise.
558
+ sandbox: true,
559
+ },
560
+ })
561
+
562
+ mainWindow.loadFile(path.join(__dirname, '..', 'public', 'index.html'))
563
+
564
+ mainWindow.on('close', () => {
565
+ if (mainWindow && !mainWindow.isDestroyed()) {
566
+ store.windowBounds = mainWindow.getBounds()
567
+ saveStore()
568
+ }
569
+ })
570
+ mainWindow.on('closed', () => { mainWindow = null })
571
+
572
+ // ─── Visibility events for physics pause ─────────────────────
573
+ //
574
+ // We deliberately listen ONLY to minimize/restore/hide/show —
575
+ // NOT to blur/focus. The simulation should keep running when
576
+ // the user merely clicks into another app, even if our window
577
+ // is fully obscured. We only pause when the OS no longer needs
578
+ // to render us at all (minimized to taskbar, hidden via Cmd+H,
579
+ // or moved to another virtual desktop and explicitly hidden).
580
+ //
581
+ // This matches user intuition: "I didn't tell it to stop, so it
582
+ // shouldn't stop." It also lets the user keep CodeSynapt alive
583
+ // on a second monitor while they work in another app.
584
+ // ─────────────────────────────────────────────────────────────
585
+ const sendVisibility = (visible) => {
586
+ if (!mainWindow?.isDestroyed()) {
587
+ mainWindow.webContents.send('window-visibility', { visible })
588
+ }
589
+ }
590
+ mainWindow.on('minimize', () => sendVisibility(false))
591
+ mainWindow.on('restore', () => sendVisibility(true))
592
+ mainWindow.on('hide', () => sendVisibility(false))
593
+ mainWindow.on('show', () => sendVisibility(true))
594
+
595
+ // Drag & drop folder onto window
596
+ mainWindow.webContents.on('did-finish-load', () => {
597
+ // Priority: CS_INITIAL_ROOT env (set by `cs ensure --launch`) > last folder
598
+ const envRoot = process.env.CS_INITIAL_ROOT
599
+ if (envRoot && fs.existsSync(envRoot) && fs.statSync(envRoot).isDirectory()) {
600
+ startScanner(path.resolve(envRoot))
601
+ } else if (store.lastFolder && fs.existsSync(store.lastFolder)) {
602
+ startScanner(store.lastFolder)
603
+ } else {
604
+ mainWindow.webContents.send('no-folder')
605
+ }
606
+ })
607
+
608
+ if (process.env.CS_DEVTOOLS === '1' || process.env.FG3D_DEVTOOLS === '1') mainWindow.webContents.openDevTools()
609
+ }
610
+
611
+ // ─── Native menu ────────────────────────────────────────────────
612
+ function rebuildMenu() {
613
+ const isMac = process.platform === 'darwin'
614
+ const pinnedItems = (store.pinnedProjects || []).map((p) => ({
615
+ label: `★ ${p.name}`,
616
+ sublabel: p.path,
617
+ click: () => startScanner(p.path),
618
+ }))
619
+ const recentItems = (store.recentFolders || [])
620
+ .filter((f) => !(store.pinnedProjects || []).some((p) => p.path === f))
621
+ .map((f) => ({ label: f, click: () => startScanner(f) }))
622
+ const recentSubmenu = (pinnedItems.length || recentItems.length)
623
+ ? [
624
+ ...(pinnedItems.length ? pinnedItems : []),
625
+ ...(pinnedItems.length && recentItems.length ? [{ type: 'separator' }] : []),
626
+ ...recentItems,
627
+ { type: 'separator' },
628
+ { label: 'Clear Recent', click: () => {
629
+ store.recentFolders = []
630
+ saveStore()
631
+ rebuildMenu()
632
+ }},
633
+ ]
634
+ : [{ label: 'No recent folders', enabled: false }]
635
+
636
+ const template = [
637
+ ...(isMac ? [{
638
+ label: app.name,
639
+ submenu: [
640
+ { role: 'about' },
641
+ { type: 'separator' },
642
+ { role: 'services' },
643
+ { type: 'separator' },
644
+ { role: 'hide' },
645
+ { role: 'hideOthers' },
646
+ { role: 'unhide' },
647
+ { type: 'separator' },
648
+ { role: 'quit' },
649
+ ],
650
+ }] : []),
651
+ {
652
+ label: 'File',
653
+ submenu: [
654
+ {
655
+ label: 'Open Folder…',
656
+ accelerator: 'CmdOrCtrl+O',
657
+ click: () => pickAndLoadFolder(),
658
+ },
659
+ {
660
+ label: 'Open Recent',
661
+ submenu: recentSubmenu,
662
+ },
663
+ { type: 'separator' },
664
+ {
665
+ label: 'Close Folder',
666
+ accelerator: 'CmdOrCtrl+W',
667
+ enabled: !!currentRoot,
668
+ click: () => {
669
+ stopScanner()
670
+ store.lastFolder = null
671
+ saveStore()
672
+ mainWindow?.webContents.send('no-folder')
673
+ },
674
+ },
675
+ { type: 'separator' },
676
+ isMac ? { role: 'close' } : { role: 'quit' },
677
+ ],
678
+ },
679
+ {
680
+ label: 'View',
681
+ submenu: [
682
+ { role: 'reload' },
683
+ { role: 'toggleDevTools' },
684
+ { type: 'separator' },
685
+ { role: 'resetZoom' },
686
+ { role: 'zoomIn' },
687
+ { role: 'zoomOut' },
688
+ { type: 'separator' },
689
+ { role: 'togglefullscreen' },
690
+ ],
691
+ },
692
+ {
693
+ label: 'Window',
694
+ submenu: [
695
+ { role: 'minimize' },
696
+ { role: 'zoom' },
697
+ ...(isMac ? [{ type: 'separator' }, { role: 'front' }] : [{ role: 'close' }]),
698
+ ],
699
+ },
700
+ ]
701
+ Menu.setApplicationMenu(Menu.buildFromTemplate(template))
702
+ }
703
+
704
+ async function pickAndLoadFolder() {
705
+ if (!mainWindow) return null
706
+ const result = await dialog.showOpenDialog(mainWindow, {
707
+ properties: ['openDirectory'],
708
+ title: 'Select folder to visualize',
709
+ defaultPath: store.lastFolder || app.getPath('home'),
710
+ })
711
+ if (result.canceled || !result.filePaths[0]) return null
712
+ await startScanner(result.filePaths[0])
713
+ return result.filePaths[0]
714
+ }
715
+
716
+ // ─── IPC handlers ───────────────────────────────────────────────
717
+ ipcMain.handle('pick-folder', () => pickAndLoadFolder())
718
+ ipcMain.handle('load-folder', (_e, folder) => startScanner(folder))
719
+ ipcMain.handle('get-state', () => ({
720
+ currentRoot,
721
+ recentFolders: store.recentFolders,
722
+ }))
723
+ ipcMain.handle('close-folder', () => {
724
+ stopScanner()
725
+ store.lastFolder = null
726
+ saveStore()
727
+ mainWindow?.webContents.send('no-folder')
728
+ })
729
+ // Path traversal guard. Resolves both paths to absolute, then checks
730
+ // that the requested file is INSIDE currentRoot (not just that its
731
+ // string starts with the same prefix, which fails on e.g.
732
+ // /home/user/proj vs /home/user/proj2 false positives).
733
+ function isInsideRoot(root, full) {
734
+ const resolvedRoot = path.resolve(root)
735
+ const resolvedFull = path.resolve(full)
736
+ const rel = path.relative(resolvedRoot, resolvedFull)
737
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
738
+ }
739
+
740
+ ipcMain.handle('read-file', (_e, id) => {
741
+ if (!currentRoot || !id) return null
742
+ try {
743
+ const full = path.join(currentRoot, id)
744
+ if (!isInsideRoot(currentRoot, full)) return null
745
+ const stat = fs.statSync(full)
746
+ if (!stat.isFile()) return null
747
+ if (stat.size > 2_000_000) return { content: '[file too large to open]', truncated: true }
748
+ const content = fs.readFileSync(full, 'utf8')
749
+ return { content }
750
+ } catch (e) {
751
+ return { content: `[error: ${e.message}]`, error: true }
752
+ }
753
+ })
754
+ // Shared write helper — used by IPC, HTTP /write, and HTTP /edit.
755
+ // All AI-issued writes flow through here so the audit trail (history
756
+ // snapshot + trace emission) is consistent regardless of entry point.
757
+ function writeFileToRoot(id, content, { source = 'ipc' } = {}) {
758
+ if (!currentRoot || !id || typeof content !== 'string') return { ok: false, error: 'invalid args' }
759
+ if (content.length > 2_000_000) return { ok: false, error: 'content too large (>2MB)' }
760
+ try {
761
+ const full = path.join(currentRoot, id)
762
+ if (!isInsideRoot(currentRoot, full)) return { ok: false, error: 'outside root' }
763
+ fs.writeFileSync(full, content, 'utf8')
764
+ snapshotHistory(currentRoot, id, content)
765
+ // Mark every external write with `tool: 'write'` so the renderer
766
+ // can tint the node green instead of the read-pink.
767
+ emitTrace('write', id)
768
+ return { ok: true, size: Buffer.byteLength(content, 'utf8'), source }
769
+ } catch (e) {
770
+ return { ok: false, error: e.message }
771
+ }
772
+ }
773
+
774
+ ipcMain.handle('write-file', (_e, id, content) => writeFileToRoot(id, content, { source: 'ipc' }))
775
+ ipcMain.handle('set-history-enabled', (_e, enabled) => {
776
+ historyEnabled = !!enabled
777
+ return { ok: true, enabled: historyEnabled }
778
+ })
779
+ ipcMain.handle('get-history-enabled', () => historyEnabled)
780
+ ipcMain.handle('list-history', (_e, id) => listHistory(currentRoot, id))
781
+ ipcMain.handle('read-history', (_e, id, ts) => readHistorySnap(currentRoot, id, ts))
782
+ ipcMain.handle('restore-history', (_e, id, ts) => {
783
+ if (!currentRoot || !id || !ts) return { ok: false }
784
+ const content = readHistorySnap(currentRoot, id, ts)
785
+ if (content === null) return { ok: false, error: 'snapshot not found' }
786
+ try {
787
+ const full = path.join(currentRoot, id)
788
+ if (!isInsideRoot(currentRoot, full)) return { ok: false, error: 'outside root' }
789
+ fs.writeFileSync(full, content, 'utf8')
790
+ snapshotHistory(currentRoot, id, content)
791
+ return { ok: true, content }
792
+ } catch (e) {
793
+ return { ok: false, error: e.message }
794
+ }
795
+ })
796
+
797
+ // ─── Session change log ────────────────────────────────────────
798
+ // Tracks every file modification detected by the scanner during this
799
+ // session — what AI editing agents (Claude Code, Cursor) write hits
800
+ // this list. Captures first-seen content on first detection so we can
801
+ // generate diffs for the "show all changes" view.
802
+ const sessionChanges = new Map() // id -> { firstAt, lastAt, count, firstSeen, currentSize, currentLoc }
803
+ function trackChange(id, content) {
804
+ const existing = sessionChanges.get(id)
805
+ const now = Date.now()
806
+ const loc = content ? content.split('\n').length : 0
807
+ const size = Buffer.byteLength(content || '', 'utf8')
808
+ if (existing) {
809
+ existing.lastAt = now
810
+ existing.count += 1
811
+ existing.currentSize = size
812
+ existing.currentLoc = loc
813
+ } else {
814
+ // First change we see — we already lost the original "before".
815
+ // Capture the current content as "firstSeen" so subsequent changes
816
+ // have something to diff against.
817
+ sessionChanges.set(id, {
818
+ firstAt: now, lastAt: now, count: 1,
819
+ firstSeen: content || '',
820
+ firstSeenSize: size, firstSeenLoc: loc,
821
+ currentSize: size, currentLoc: loc,
822
+ })
823
+ }
824
+ }
825
+ function listSessionChanges() {
826
+ const items = []
827
+ for (const [id, c] of sessionChanges.entries()) {
828
+ items.push({
829
+ id,
830
+ firstAt: c.firstAt, lastAt: c.lastAt, count: c.count,
831
+ sizeBefore: c.firstSeenSize, sizeAfter: c.currentSize,
832
+ locBefore: c.firstSeenLoc, locAfter: c.currentLoc,
833
+ sizeDelta: c.currentSize - c.firstSeenSize,
834
+ locDelta: c.currentLoc - c.firstSeenLoc,
835
+ })
836
+ }
837
+ items.sort((a, b) => b.lastAt - a.lastAt)
838
+ return items
839
+ }
840
+ function getChangeDiff(id) {
841
+ const c = sessionChanges.get(id)
842
+ if (!c) return null
843
+ let after = null
844
+ try {
845
+ if (!currentRoot) return null
846
+ const full = path.join(currentRoot, id)
847
+ if (!isInsideRoot(currentRoot, full)) return null
848
+ const stat = fs.statSync(full)
849
+ if (stat.size > 2_000_000) return { error: 'file too large' }
850
+ after = fs.readFileSync(full, 'utf8')
851
+ } catch (e) { return { error: e.message } }
852
+ return {
853
+ id, firstAt: c.firstAt, lastAt: c.lastAt, count: c.count,
854
+ before: c.firstSeen,
855
+ after,
856
+ lines: makeLineDiff(c.firstSeen, after),
857
+ }
858
+ }
859
+ // Tiny LCS-based unified diff. Returns array of { tag: 'eq'|'add'|'del', a?: lineNo, b?: lineNo, text }.
860
+ function makeLineDiff(before, after) {
861
+ const A = (before || '').split('\n')
862
+ const B = (after || '').split('\n')
863
+ const n = A.length, m = B.length
864
+ // LCS table — bail out if too large to avoid huge allocations.
865
+ if (n * m > 2_000_000) return [{ tag: 'note', text: 'file too large to diff line-by-line' }]
866
+ const dp = new Uint32Array((n + 1) * (m + 1))
867
+ const W = m + 1
868
+ for (let i = n - 1; i >= 0; i--) {
869
+ for (let j = m - 1; j >= 0; j--) {
870
+ dp[i * W + j] = A[i] === B[j]
871
+ ? dp[(i + 1) * W + (j + 1)] + 1
872
+ : Math.max(dp[(i + 1) * W + j], dp[i * W + (j + 1)])
873
+ }
874
+ }
875
+ const out = []
876
+ let i = 0, j = 0
877
+ while (i < n && j < m) {
878
+ if (A[i] === B[j]) { out.push({ tag: 'eq', a: i + 1, b: j + 1, text: A[i] }); i++; j++ }
879
+ else if (dp[(i + 1) * W + j] >= dp[i * W + (j + 1)]) { out.push({ tag: 'del', a: i + 1, text: A[i] }); i++ }
880
+ else { out.push({ tag: 'add', b: j + 1, text: B[j] }); j++ }
881
+ }
882
+ while (i < n) { out.push({ tag: 'del', a: i + 1, text: A[i] }); i++ }
883
+ while (j < m) { out.push({ tag: 'add', b: j + 1, text: B[j] }); j++ }
884
+ return out
885
+ }
886
+
887
+ // ─── File history ──────────────────────────────────────────────
888
+ const HISTORY_DIR_NAME = '.codesynapt'
889
+ const LEGACY_HISTORY_DIR_NAME = '.filegraph3d' // renamed in 0.14.6; migrate on first scan
890
+ const HISTORY_MAX_PER_FILE = 3
891
+ let historyEnabled = false // default OFF — user toggles on in settings
892
+ function historyDirFor(root, id) {
893
+ const safe = id.replace(/[\\/:]/g, '__').replace(/[^A-Za-z0-9._-]/g, '_')
894
+ return path.join(root, HISTORY_DIR_NAME, 'history', safe)
895
+ }
896
+ // One-time migration of the per-project data folder (history/ + traces/).
897
+ // If a user upgraded from <0.14.6, their backups live under .filegraph3d/.
898
+ // We rename the whole folder so both history/ and traces/ subdirs move together.
899
+ // Skipped if the new folder already exists (user has both somehow — leave them).
900
+ function migrateLegacyHistoryDir(root) {
901
+ if (!root) return
902
+ try {
903
+ const oldPath = path.join(root, LEGACY_HISTORY_DIR_NAME)
904
+ const newPath = path.join(root, HISTORY_DIR_NAME)
905
+ if (fs.existsSync(oldPath) && !fs.existsSync(newPath) && fs.statSync(oldPath).isDirectory()) {
906
+ fs.renameSync(oldPath, newPath)
907
+ log.info('migrated legacy history dir', { from: LEGACY_HISTORY_DIR_NAME, to: HISTORY_DIR_NAME, root })
908
+ }
909
+ } catch (e) {
910
+ log.warn('history dir migration skipped', { error: e.message })
911
+ }
912
+ }
913
+ function snapshotHistory(root, id, content) {
914
+ if (!historyEnabled) return
915
+ if (!root || !id) return
916
+ try {
917
+ const dir = historyDirFor(root, id)
918
+ fs.mkdirSync(dir, { recursive: true })
919
+ const files = fs.readdirSync(dir)
920
+ .filter((f) => f.endsWith('.snap'))
921
+ .map((f) => ({ name: f, ts: parseInt(f, 10) }))
922
+ .filter((f) => !isNaN(f.ts))
923
+ .sort((a, b) => b.ts - a.ts)
924
+ // Skip if newest snapshot is identical (avoid stutter from chokidar re-firing)
925
+ if (files.length > 0) {
926
+ try {
927
+ const prev = fs.readFileSync(path.join(dir, files[0].name), 'utf8')
928
+ if (prev === content) return
929
+ } catch {}
930
+ }
931
+ fs.writeFileSync(path.join(dir, `${Date.now()}.snap`), content, 'utf8')
932
+ // Prune to last HISTORY_MAX_PER_FILE
933
+ const all = fs.readdirSync(dir)
934
+ .filter((f) => f.endsWith('.snap'))
935
+ .map((f) => ({ name: f, ts: parseInt(f, 10) }))
936
+ .filter((f) => !isNaN(f.ts))
937
+ .sort((a, b) => b.ts - a.ts)
938
+ for (const f of all.slice(HISTORY_MAX_PER_FILE)) {
939
+ try { fs.unlinkSync(path.join(dir, f.name)) } catch {}
940
+ }
941
+ } catch {}
942
+ }
943
+ function listHistory(root, id) {
944
+ if (!root || !id) return []
945
+ try {
946
+ const dir = historyDirFor(root, id)
947
+ if (!fs.existsSync(dir)) return []
948
+ return fs.readdirSync(dir)
949
+ .filter((f) => f.endsWith('.snap'))
950
+ .map((f) => {
951
+ const ts = parseInt(f, 10)
952
+ if (isNaN(ts)) return null
953
+ try {
954
+ const stat = fs.statSync(path.join(dir, f))
955
+ return { ts, size: stat.size }
956
+ } catch { return null }
957
+ })
958
+ .filter(Boolean)
959
+ .sort((a, b) => b.ts - a.ts)
960
+ } catch { return [] }
961
+ }
962
+ function readHistorySnap(root, id, ts) {
963
+ if (!root || !id || !ts) return null
964
+ try {
965
+ const dir = historyDirFor(root, id)
966
+ const file = path.join(dir, `${ts}.snap`)
967
+ if (!fs.existsSync(file)) return null
968
+ return fs.readFileSync(file, 'utf8')
969
+ } catch { return null }
970
+ }
971
+ ipcMain.handle('reveal-in-os', (_e, id) => {
972
+ if (!currentRoot || !id) return
973
+ const full = path.join(currentRoot, id)
974
+ if (isInsideRoot(currentRoot, full) && fs.existsSync(full)) {
975
+ shell.showItemInFolder(full)
976
+ }
977
+ })
978
+ ipcMain.handle('open-in-editor', (_e, id) => {
979
+ if (!currentRoot || !id) return
980
+ const full = path.join(currentRoot, id)
981
+ if (isInsideRoot(currentRoot, full) && fs.existsSync(full)) {
982
+ shell.openPath(full)
983
+ }
984
+ })
985
+
986
+ // ─── Plugin system ──────────────────────────────────────────────
987
+ const pluginLoader = require('./plugin-loader.cjs')
988
+
989
+ ipcMain.handle('list-plugins', () => {
990
+ try {
991
+ return pluginLoader.discoverPlugins()
992
+ } catch (err) {
993
+ console.error('[main] plugin discovery failed:', err)
994
+ return []
995
+ }
996
+ })
997
+
998
+ ipcMain.handle('open-plugin-dir', () => {
999
+ try {
1000
+ const dir = pluginLoader.ensurePluginDir()
1001
+ shell.openPath(dir)
1002
+ return dir
1003
+ } catch (err) {
1004
+ console.error('[main] cannot open plugin dir:', err)
1005
+ return null
1006
+ }
1007
+ })
1008
+
1009
+ ipcMain.handle('plugin-dir', () => pluginLoader.getPluginDir())
1010
+
1011
+ // ─── Pinned projects ──────────────────────────────────────────
1012
+ function basenameOf(p) {
1013
+ return p.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean).pop() || p
1014
+ }
1015
+ // ─── Panel data IPCs (renderer → main, bypasses HTTP/CSP) ─────
1016
+ // These mirror the HTTP control API endpoints but are reached via the
1017
+ // preload bridge instead of fetch(). The renderer can't fetch its own
1018
+ // HTTP server from the file:// origin under the current CSP, so we
1019
+ // expose the data through IPC. Same underlying functions are shared
1020
+ // with the HTTP layer.
1021
+ ipcMain.handle('panel:tour', () => buildTour())
1022
+ ipcMain.handle('panel:timeline', async () => await buildTimeline())
1023
+ ipcMain.handle('panel:changes', () => listSessionChanges())
1024
+ ipcMain.handle('panel:change-diff', (_e, id) => getChangeDiff(id))
1025
+ ipcMain.handle('panel:packages', () => buildPackagesCached())
1026
+ ipcMain.handle('panel:package', (_e, name) => buildPackageDetail(name))
1027
+ ipcMain.handle('panel:legacy', async () => await buildLegacyCached())
1028
+ ipcMain.handle('trace:log', (_e, opts = {}) => {
1029
+ let evs = traceLog
1030
+ if (opts.tool) evs = evs.filter((e) => e.tool === opts.tool)
1031
+ if (opts.limit) evs = evs.slice(-opts.limit)
1032
+ return { sessionId: traceSessionId, events: evs, totalAvailable: traceLog.length }
1033
+ })
1034
+ ipcMain.handle('trace:stats', () => ({ sessionId: traceSessionId, ...computeTraceStats(traceLog) }))
1035
+ ipcMain.handle('trace:sessions', () => ({
1036
+ sessions: listTraceSessions(currentRoot), currentSessionId: traceSessionId,
1037
+ }))
1038
+ ipcMain.handle('trace:session', (_e, id) => {
1039
+ const data = readTraceSession(currentRoot, id)
1040
+ if (!data) return null
1041
+ return { ...data, stats: computeTraceStats(data.events) }
1042
+ })
1043
+ ipcMain.handle('trace:clear', () => { traceLog = []; startTraceSession(); return { newSessionId: traceSessionId } })
1044
+ ipcMain.handle('trace:export', async (_e, exportPath) => {
1045
+ if (!exportPath) {
1046
+ const r = await dialog.showSaveDialog(mainWindow, {
1047
+ title: 'Export AI trace session',
1048
+ defaultPath: `cs-trace-${traceSessionId}.json`,
1049
+ filters: [{ name: 'JSON', extensions: ['json'] }],
1050
+ })
1051
+ if (r.canceled || !r.filePath) return { canceled: true }
1052
+ exportPath = r.filePath
1053
+ }
1054
+ try {
1055
+ const stats = computeTraceStats(traceLog)
1056
+ const out = {
1057
+ sessionId: traceSessionId, root: currentRoot,
1058
+ startedAt: traceSessionStartedAt, exportedAt: Date.now(),
1059
+ stats, events: traceLog,
1060
+ }
1061
+ fs.writeFileSync(exportPath, JSON.stringify(out, null, 2), 'utf8')
1062
+ return { ok: true, path: exportPath, eventCount: traceLog.length }
1063
+ } catch (e) { return { error: e.message } }
1064
+ })
1065
+
1066
+ ipcMain.handle('list-projects', () => ({
1067
+ pinned: store.pinnedProjects || [],
1068
+ recent: store.recentFolders || [],
1069
+ current: currentRoot,
1070
+ }))
1071
+ ipcMain.handle('pin-project', (_e, payload) => {
1072
+ const path = (payload && payload.path) || ''
1073
+ if (!path) return { ok: false, error: 'path required' }
1074
+ const name = (payload && payload.name) || basenameOf(path)
1075
+ const color = (payload && payload.color) || null
1076
+ store.pinnedProjects = (store.pinnedProjects || []).filter((p) => p.path !== path)
1077
+ store.pinnedProjects.unshift({ path, name, color, pinnedAt: Date.now() })
1078
+ saveStore()
1079
+ rebuildMenu()
1080
+ return { ok: true, pinned: store.pinnedProjects }
1081
+ })
1082
+ ipcMain.handle('unpin-project', (_e, path) => {
1083
+ store.pinnedProjects = (store.pinnedProjects || []).filter((p) => p.path !== path)
1084
+ saveStore()
1085
+ rebuildMenu()
1086
+ return { ok: true, pinned: store.pinnedProjects }
1087
+ })
1088
+ ipcMain.handle('rename-project', (_e, payload) => {
1089
+ const path = (payload && payload.path) || ''
1090
+ const name = (payload && payload.name) || ''
1091
+ if (!path || !name) return { ok: false, error: 'path and name required' }
1092
+ const list = store.pinnedProjects || []
1093
+ const item = list.find((p) => p.path === path)
1094
+ if (!item) return { ok: false, error: 'not pinned' }
1095
+ item.name = name
1096
+ saveStore()
1097
+ rebuildMenu()
1098
+ return { ok: true, pinned: list }
1099
+ })
1100
+
1101
+ // ─── HTTP control server (for CLI + MCP) ───────────────────────
1102
+ // Exposes read-only graph queries and UI control actions on
1103
+ // http://127.0.0.1:PORT (default 7707). Local-only. Port and
1104
+ // enable/disable are configurable via env vars and persistent settings.
1105
+ const CONTROL_DEFAULT_PORT = parseInt(process.env.CS_PORT || process.env.FG3D_PORT || '7707', 10)
1106
+ let controlServer = null
1107
+ let controlPort = CONTROL_DEFAULT_PORT
1108
+
1109
+ function getGraphState() {
1110
+ if (!scanner) return null
1111
+ return { root: currentRoot, ...scanner.snapshot() }
1112
+ }
1113
+ function findNode(id) {
1114
+ if (!scanner) return null
1115
+ const f = scanner.files.get(id)
1116
+ if (!f) return null
1117
+ return {
1118
+ id: f.id, ext: f.ext, loc: f.loc, size: f.size,
1119
+ importCount: f.imports.length,
1120
+ hasDynamicResolution: (f.dynamicPatterns || []).length > 0,
1121
+ dynamicPatterns: f.dynamicPatterns || [],
1122
+ confidence: f.confidence || 'high',
1123
+ pkg: f.pkg || null,
1124
+ lastSeenAt: f.lastSeenAt,
1125
+ }
1126
+ }
1127
+
1128
+ // Approximate token count — Anthropic's published rule of thumb is
1129
+ // ~3.5–4 chars/token for code. Use 4 conservatively for budgeting.
1130
+ function estimateTokens(obj) {
1131
+ try { return Math.ceil(JSON.stringify(obj).length / 4) } catch { return 0 }
1132
+ }
1133
+ function withMeta(payload, extra = {}) {
1134
+ const meta = {
1135
+ scannedAt: scanner?._lastSnapshotAt || Date.now(),
1136
+ serverTime: Date.now(),
1137
+ ...extra,
1138
+ }
1139
+ meta.tokenEstimate = estimateTokens({ ...payload, meta })
1140
+ return { ...payload, meta }
1141
+ }
1142
+
1143
+ // Cached wrapper around buildSummary — recomputes only when the graph
1144
+ // snapshot version changes. Lazy: cost paid only when summary is read.
1145
+ let _summaryCache = { version: -1, data: null }
1146
+ function buildSummaryCached() {
1147
+ if (!scanner) return null
1148
+ const v = scanner.snapshotVersion || 0
1149
+ if (_summaryCache.version === v && _summaryCache.data) return _summaryCache.data
1150
+ const data = buildSummary()
1151
+ // Race guard: only cache if version didn't shift during compute.
1152
+ if (scanner.snapshotVersion === v) _summaryCache = { version: v, data }
1153
+ return data
1154
+ }
1155
+
1156
+ // Project summary — Layer-1 cheap overview for AI to read first
1157
+ function buildSummary() {
1158
+ if (!scanner) return null
1159
+ const files = [...scanner.files.values()]
1160
+ const byExt = {}
1161
+ let dynamicCount = 0
1162
+ const incoming = new Map()
1163
+ const outgoing = new Map()
1164
+ for (const e of scanner.edges) {
1165
+ incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
1166
+ outgoing.set(e.s, (outgoing.get(e.s) || 0) + 1)
1167
+ }
1168
+ for (const f of files) {
1169
+ byExt[f.ext || 'other'] = (byExt[f.ext || 'other'] || 0) + 1
1170
+ if ((f.dynamicPatterns || []).length > 0) dynamicCount++
1171
+ }
1172
+ // Top hubs
1173
+ const topHubs = files
1174
+ .map((f) => ({ id: f.id, incoming: incoming.get(f.id) || 0, ext: f.ext }))
1175
+ .filter((h) => h.incoming >= 2)
1176
+ .sort((a, b) => b.incoming - a.incoming)
1177
+ .slice(0, 10)
1178
+ // Top folders by file count
1179
+ const folderCount = new Map()
1180
+ for (const f of files) {
1181
+ const p = f.id.includes('/') ? f.id.slice(0, f.id.lastIndexOf('/')) : '(root)'
1182
+ const top = p.split('/')[0] || '(root)'
1183
+ folderCount.set(top, (folderCount.get(top) || 0) + 1)
1184
+ }
1185
+ const topFolders = [...folderCount.entries()]
1186
+ .sort((a, b) => b[1] - a[1]).slice(0, 5)
1187
+ .map(([path, files]) => ({ path, files }))
1188
+ // Orphans (no incoming AND no outgoing)
1189
+ let orphanCount = 0
1190
+ for (const f of files) {
1191
+ if ((incoming.get(f.id) || 0) === 0 && (outgoing.get(f.id) || 0) === 0) orphanCount++
1192
+ }
1193
+ // Ext breakdown (top 5)
1194
+ const extMix = Object.entries(byExt).sort((a, b) => b[1] - a[1]).slice(0, 5)
1195
+ .reduce((o, [k, v]) => (o[k] = v, o), {})
1196
+ // External services (already aggregated)
1197
+ const ext = getExternalUrls()
1198
+ const topExternal = ext.domains.slice(0, 5).map((d) => d.domain)
1199
+ // `asset` edges are HTML/CSS/etc. referencing image/script/style files
1200
+ // (jQuery, jazzy.js, theme CSS, image tags). They're useful when you
1201
+ // ask "what assets does this page link to?" but they shouldn't dominate
1202
+ // the code-structure stats — a 300-page Jazzy-generated docs tree would
1203
+ // make `edgeCount` swing by thousands without representing any source
1204
+ // dependency. Split them into their own counter.
1205
+ let codeEdges = 0, assetEdges = 0
1206
+ for (const e of scanner.edges) {
1207
+ if (e.kind === 'asset' || e.k === 'asset') assetEdges++
1208
+ else codeEdges++
1209
+ }
1210
+ return {
1211
+ root: currentRoot,
1212
+ fileCount: files.length,
1213
+ edgeCount: codeEdges,
1214
+ assetEdgeCount: assetEdges,
1215
+ extMix,
1216
+ topFolders,
1217
+ topHubs,
1218
+ orphanCount,
1219
+ dynamicPatternFileCount: dynamicCount,
1220
+ externalDomainCount: ext.domains.length,
1221
+ externalDomainsTop: topExternal,
1222
+ historyEnabled,
1223
+ }
1224
+ }
1225
+ function getDeps(id) {
1226
+ if (!scanner) return []
1227
+ return scanner.edges.filter((e) => e.s === id)
1228
+ }
1229
+ function getUsers(id) {
1230
+ if (!scanner) return []
1231
+ return scanner.edges.filter((e) => e.t === id)
1232
+ }
1233
+ // Package-level overview. For each detected package compute file count,
1234
+ // LOC total, edges in/out of the package (file-level), and dependents.
1235
+ // Cached against snapshotVersion like buildSummary.
1236
+ let _packagesCache = { version: -1, data: null }
1237
+ function buildPackagesCached() {
1238
+ if (!scanner) return null
1239
+ const v = scanner.snapshotVersion || 0
1240
+ if (_packagesCache.version === v && _packagesCache.data) return _packagesCache.data
1241
+ const m = scanner.monorepo
1242
+ if (!m || m.kind === 'none' || !m.packages.length) {
1243
+ const empty = { kind: m?.kind || 'none', packages: [], pkgEdges: [], rootIsPackage: !!m?.rootIsPackage }
1244
+ _packagesCache = { version: v, data: empty }
1245
+ return empty
1246
+ }
1247
+ // Bucket files per package
1248
+ const filesByPkg = new Map()
1249
+ for (const f of scanner.files.values()) {
1250
+ if (!f.pkg) continue
1251
+ const arr = filesByPkg.get(f.pkg) || []
1252
+ arr.push(f)
1253
+ filesByPkg.set(f.pkg, arr)
1254
+ }
1255
+ // Incoming/outgoing edge counts per package (file-level)
1256
+ const edgesIn = new Map(), edgesOut = new Map()
1257
+ for (const e of scanner.edges) {
1258
+ const sf = scanner.files.get(e.s), tf = scanner.files.get(e.t)
1259
+ if (!sf || !tf) continue
1260
+ if (sf.pkg && sf.pkg !== tf.pkg) edgesOut.set(sf.pkg, (edgesOut.get(sf.pkg) || 0) + 1)
1261
+ if (tf.pkg && sf.pkg !== tf.pkg) edgesIn.set(tf.pkg, (edgesIn.get(tf.pkg) || 0) + 1)
1262
+ }
1263
+ const packages = m.packages.map((p) => {
1264
+ const files = filesByPkg.get(p.name) || []
1265
+ const loc = files.reduce((s, f) => s + (f.loc || 0), 0)
1266
+ const size = files.reduce((s, f) => s + (f.size || 0), 0)
1267
+ return {
1268
+ name: p.name,
1269
+ relRoot: p.relRoot,
1270
+ manifest: p.manifest,
1271
+ language: p.language,
1272
+ kind: p.kind,
1273
+ fileCount: files.length,
1274
+ loc, size,
1275
+ crossPackageImports: edgesOut.get(p.name) || 0,
1276
+ crossPackageDependents: edgesIn.get(p.name) || 0,
1277
+ }
1278
+ })
1279
+ const data = {
1280
+ kind: m.kind,
1281
+ rootIsPackage: m.rootIsPackage,
1282
+ packages,
1283
+ pkgEdges: scanner.pkgEdges || [],
1284
+ }
1285
+ if (scanner.snapshotVersion === v) _packagesCache = { version: v, data }
1286
+ return data
1287
+ }
1288
+
1289
+ // Detail view for a single package: files (sorted by mass), declared
1290
+ // dependencies (from manifest), incoming/outgoing cross-package edges
1291
+ // with the specific file pairs that make up each edge.
1292
+ function buildPackageDetail(name) {
1293
+ if (!scanner) return null
1294
+ const m = scanner.monorepo
1295
+ const pkg = m?.packages?.find((p) => p.name === name)
1296
+ if (!pkg) return null
1297
+ const files = []
1298
+ const incoming = new Map() // for sorting by mass
1299
+ for (const e of scanner.edges) incoming.set(e.t, (incoming.get(e.t) || 0) + 1)
1300
+ for (const f of scanner.files.values()) {
1301
+ if (f.pkg !== name) continue
1302
+ files.push({
1303
+ id: f.id, ext: f.ext, loc: f.loc, size: f.size,
1304
+ mass: incoming.get(f.id) || 0,
1305
+ })
1306
+ }
1307
+ files.sort((a, b) => b.mass - a.mass)
1308
+ // Cross-package edges involving this package
1309
+ const outgoingEdges = [] // edges from THIS package to others
1310
+ const incomingEdges = [] // edges from others into THIS package
1311
+ for (const e of scanner.edges) {
1312
+ const sf = scanner.files.get(e.s), tf = scanner.files.get(e.t)
1313
+ if (!sf || !tf || !sf.pkg || !tf.pkg || sf.pkg === tf.pkg) continue
1314
+ if (sf.pkg === name) outgoingEdges.push({ s: e.s, t: e.t, k: e.k, toPkg: tf.pkg })
1315
+ if (tf.pkg === name) incomingEdges.push({ s: e.s, t: e.t, k: e.k, fromPkg: sf.pkg })
1316
+ }
1317
+ // Declared deps from manifest
1318
+ let declared = []
1319
+ try {
1320
+ if (pkg.manifest === 'package.json') {
1321
+ const j = JSON.parse(fs.readFileSync(path.join(pkg.root, 'package.json'), 'utf8'))
1322
+ const collect = (field) => {
1323
+ if (!j[field]) return
1324
+ for (const [k, v] of Object.entries(j[field])) declared.push({ name: k, spec: v, kind: field })
1325
+ }
1326
+ collect('dependencies'); collect('devDependencies'); collect('peerDependencies')
1327
+ }
1328
+ } catch {}
1329
+ return {
1330
+ name, relRoot: pkg.relRoot, manifest: pkg.manifest,
1331
+ language: pkg.language, kind: pkg.kind,
1332
+ fileCount: files.length, files,
1333
+ outgoingEdges, incomingEdges,
1334
+ declared,
1335
+ }
1336
+ }
1337
+
1338
+ function searchFiles(q) {
1339
+ if (!scanner || !q) return []
1340
+ const needle = q.toLowerCase()
1341
+ const out = []
1342
+ for (const f of scanner.files.values()) {
1343
+ if (f.id.toLowerCase().includes(needle)) out.push(f.id)
1344
+ if (out.length >= 100) break
1345
+ }
1346
+ return out
1347
+ }
1348
+
1349
+ // Predict the impact of editing a file: BFS through dependents (or
1350
+ // dependencies), tally total size + LOC + categorize by path. Token
1351
+ // estimate uses the ~4-chars-per-token heuristic Anthropic publishes.
1352
+ function computeBlastRadius(id, depth = 3, direction = 'users') {
1353
+ if (!scanner || !scanner.files.has(id)) return null
1354
+ const visited = new Set([id])
1355
+ let frontier = new Set([id])
1356
+ const byDepth = [{ depth: 0, ids: [id] }]
1357
+ for (let d = 1; d <= depth; d++) {
1358
+ const next = new Set()
1359
+ for (const fid of frontier) {
1360
+ const edges = direction === 'users' ? getUsers(fid) : getDeps(fid)
1361
+ for (const e of edges) {
1362
+ const neighbor = direction === 'users' ? e.s : e.t
1363
+ if (visited.has(neighbor)) continue
1364
+ visited.add(neighbor)
1365
+ next.add(neighbor)
1366
+ }
1367
+ }
1368
+ if (next.size === 0) break
1369
+ byDepth.push({ depth: d, ids: [...next] })
1370
+ frontier = next
1371
+ }
1372
+ const files = [...visited].map((fid) => {
1373
+ const f = scanner.files.get(fid)
1374
+ return f ? { id: fid, ext: f.ext, loc: f.loc, size: f.size } : null
1375
+ }).filter(Boolean)
1376
+ const totalSize = files.reduce((s, f) => s + f.size, 0)
1377
+ const totalLoc = files.reduce((s, f) => s + f.loc, 0)
1378
+ // Anthropic publishes ~3.5–4 chars/token for code. Use 4 as a round
1379
+ // estimate; this is the same heuristic the SDK docs recommend.
1380
+ const tokenEstimate = Math.round(totalSize / 4)
1381
+ const categories = { tests: 0, source: 0, config: 0, docs: 0, other: 0 }
1382
+ for (const f of files) {
1383
+ if (/(?:^|\/)(?:__tests__|test|tests|spec|e2e)\/|\.(?:test|spec)\.[a-z]+$/i.test(f.id)) categories.tests++
1384
+ else if (/\.(?:json|ya?ml|toml|env|config|conf|ini|lock)(?:\.\w+)?$|^\.[a-z]+rc/i.test(f.id)) categories.config++
1385
+ else if (/\.(?:md|mdx|txt|rst|adoc)$/i.test(f.id)) categories.docs++
1386
+ else if (f.ext) categories.source++
1387
+ else categories.other++
1388
+ }
1389
+ return {
1390
+ seed: id, direction, depth,
1391
+ totalFiles: files.length,
1392
+ totalSize, totalLoc, tokenEstimate, categories,
1393
+ files: files.sort((a, b) => b.size - a.size).slice(0, 200),
1394
+ byDepth,
1395
+ }
1396
+ }
1397
+
1398
+ // Build a per-file "first introduced at" timeline from `git log`.
1399
+ // Cached after first build because git log over a large repo is slow.
1400
+ // Returns: { points: [{ ts, hash, subject, addedFiles: [...] }], firstAt, lastAt, isGit, error? }
1401
+ let timelineCache = { root: null, data: null, building: false }
1402
+ async function buildTimeline() {
1403
+ if (!currentRoot) return { error: 'no folder loaded', isGit: false }
1404
+ if (timelineCache.root === currentRoot && timelineCache.data) return timelineCache.data
1405
+ if (timelineCache.building) return { error: 'building', isGit: true, building: true }
1406
+ timelineCache.building = true
1407
+ try {
1408
+ await pExecFile('git', ['rev-parse', '--git-dir'], { cwd: currentRoot })
1409
+ } catch {
1410
+ timelineCache.building = false
1411
+ return { error: 'not a git repository', isGit: false }
1412
+ }
1413
+ try {
1414
+ const { stdout } = await pExecFile(
1415
+ 'git',
1416
+ ['log', '--reverse', '--diff-filter=A', '--name-only', '--format=__C__%H|%at|%s'],
1417
+ { cwd: currentRoot, maxBuffer: 100 * 1024 * 1024 }
1418
+ )
1419
+ const points = []
1420
+ let cur = null
1421
+ for (const line of stdout.split('\n')) {
1422
+ if (line.startsWith('__C__')) {
1423
+ const [hash, atStr, ...subj] = line.slice(5).split('|')
1424
+ cur = { hash, ts: parseInt(atStr, 10) * 1000, subject: subj.join('|'), addedFiles: [] }
1425
+ points.push(cur)
1426
+ } else if (line && cur) {
1427
+ // Only keep files we currently track (filters renames + deletes)
1428
+ const id = line.replace(/\\/g, '/')
1429
+ if (scanner?.files?.has(id)) cur.addedFiles.push(id)
1430
+ }
1431
+ }
1432
+ // Drop empty commits (commits that added only files we no longer have)
1433
+ const filtered = points.filter((p) => p.addedFiles.length > 0)
1434
+ const data = {
1435
+ isGit: true,
1436
+ points: filtered,
1437
+ firstAt: filtered[0]?.ts || Date.now(),
1438
+ lastAt: filtered[filtered.length - 1]?.ts || Date.now(),
1439
+ commitCount: filtered.length,
1440
+ }
1441
+ timelineCache = { root: currentRoot, data, building: false }
1442
+ return data
1443
+ } catch (e) {
1444
+ timelineCache.building = false
1445
+ return { error: e.message, isGit: true }
1446
+ }
1447
+ }
1448
+
1449
+ // Heuristic-only onboarding tour. Picks likely entry points (index/
1450
+ // main/app/server at the project root or under src/), then the top
1451
+ // hub files by incoming-import count. Each stop has a generated
1452
+ // human-readable hint. An MCP client can call cs_trace({action:'tour'}) to get
1453
+ // the same script for narrating.
1454
+ function buildTour() {
1455
+ if (!scanner) return null
1456
+ const files = [...scanner.files.values()]
1457
+ const stops = []
1458
+ const seen = new Set()
1459
+ const entryRe = /^(?:src\/)?(?:index|main|app|server|cli|bin)(?:\.[a-z]+)+$/i
1460
+ const entries = files.filter((f) => entryRe.test(f.id)).sort((a, b) => a.id.length - b.id.length).slice(0, 3)
1461
+ for (const f of entries) {
1462
+ if (seen.has(f.id)) continue
1463
+ seen.add(f.id)
1464
+ stops.push({
1465
+ id: f.id,
1466
+ kind: 'entry',
1467
+ hint: `Entry point — likely where execution starts. ${f.ext.toUpperCase()} file, ${f.loc} LOC.`,
1468
+ })
1469
+ }
1470
+ // Inbound count per file
1471
+ const inCount = new Map()
1472
+ for (const e of scanner.edges) inCount.set(e.t, (inCount.get(e.t) || 0) + 1)
1473
+ const hubs = files
1474
+ .map((f) => ({ ...f, inCount: inCount.get(f.id) || 0 }))
1475
+ .filter((f) => f.inCount >= 2 && !seen.has(f.id))
1476
+ .sort((a, b) => b.inCount - a.inCount)
1477
+ .slice(0, 5)
1478
+ for (const f of hubs) {
1479
+ seen.add(f.id)
1480
+ stops.push({
1481
+ id: f.id,
1482
+ kind: 'hub',
1483
+ hint: `Hub file — ${f.inCount} other files import this. Core utility or shared module.`,
1484
+ })
1485
+ }
1486
+ // External-call concentrators
1487
+ const ext = getExternalUrls()
1488
+ const topCallers = new Map()
1489
+ for (const d of ext.domains) {
1490
+ for (const c of d.callers) {
1491
+ topCallers.set(c.file, (topCallers.get(c.file) || 0) + 1)
1492
+ }
1493
+ }
1494
+ const apiFiles = [...topCallers.entries()]
1495
+ .filter(([id]) => !seen.has(id))
1496
+ .sort((a, b) => b[1] - a[1])
1497
+ .slice(0, 3)
1498
+ for (const [id, count] of apiFiles) {
1499
+ seen.add(id)
1500
+ stops.push({
1501
+ id, kind: 'api',
1502
+ hint: `External API integration — calls ${count} different external URL${count === 1 ? '' : 's'}.`,
1503
+ })
1504
+ }
1505
+ return { stops, totalFiles: scanner.files.size }
1506
+ }
1507
+
1508
+ function getExternalUrls() {
1509
+ if (!scanner) return { domains: [], totalCalls: 0 }
1510
+ const byDomain = new Map()
1511
+ let total = 0
1512
+ // Helper: register one URL occurrence
1513
+ const add = (rawUrl, fileId, methodHint) => {
1514
+ const m = rawUrl.match(/^(https?|wss?):\/\/([^\/:?#]+)/i)
1515
+ if (!m) return
1516
+ const proto = m[1].toLowerCase()
1517
+ const domain = m[2].toLowerCase()
1518
+ let bucket = byDomain.get(domain)
1519
+ if (!bucket) { bucket = { domain, proto, callers: [] }; byDomain.set(domain, bucket) }
1520
+ bucket.callers.push({ file: fileId, url: rawUrl, method: methodHint || (proto.startsWith('ws') ? 'WS' : 'GET') })
1521
+ total++
1522
+ }
1523
+ for (const f of scanner.files.values()) {
1524
+ // 1. Structured apiCalls — known fetch/axios/requests patterns with method
1525
+ if (f.apiCalls && f.apiCalls.length) {
1526
+ for (const c of f.apiCalls) {
1527
+ if (/^https?:\/\//i.test(c.url)) add(c.url, f.id, c.method || 'GET')
1528
+ }
1529
+ }
1530
+ // 2. Generic URL grep — catches everything else
1531
+ if (f.externalUrls && f.externalUrls.length) {
1532
+ for (const u of f.externalUrls) add(u.url, f.id, null)
1533
+ }
1534
+ }
1535
+ // De-duplicate identical (file, url, method) triples within each domain
1536
+ for (const bucket of byDomain.values()) {
1537
+ const seen = new Set()
1538
+ bucket.callers = bucket.callers.filter((c) => {
1539
+ const k = c.file + '|' + c.url + '|' + c.method
1540
+ if (seen.has(k)) return false
1541
+ seen.add(k); return true
1542
+ })
1543
+ }
1544
+ // Recompute total after dedup
1545
+ total = 0
1546
+ for (const b of byDomain.values()) total += b.callers.length
1547
+ const domains = [...byDomain.values()].sort((a, b) => b.callers.length - a.callers.length)
1548
+ return { domains, totalCalls: total }
1549
+ }
1550
+
1551
+ function writeJson(res, status, data) {
1552
+ const body = JSON.stringify(data)
1553
+ res.writeHead(status, {
1554
+ 'Content-Type': 'application/json; charset=utf-8',
1555
+ 'Content-Length': Buffer.byteLength(body),
1556
+ 'Access-Control-Allow-Origin': '*',
1557
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1558
+ 'Access-Control-Allow-Headers': 'Content-Type',
1559
+ })
1560
+ res.end(body)
1561
+ }
1562
+
1563
+ // ─── Trace store ───────────────────────────────────────────────
1564
+ //
1565
+ // Persistent record of every node access (tool, id, timestamp) the
1566
+ // AI/CLI/MCP layer makes. Lets the user see exactly what an AI did
1567
+ // during a coding session, export it for review, or replay it.
1568
+ //
1569
+ // Storage layout: `.codesynapt/traces/session-{startTs}.jsonl`
1570
+ // One line per event. Each session ID is the unix-ms timestamp of
1571
+ // when the scanner started on that root.
1572
+ const TRACE_DIR_NAME = 'traces'
1573
+ const TRACE_MEM_CAP = 10000 // in-memory cap to prevent unbounded growth
1574
+ let traceSessionId = null // ms timestamp; set in startScanner
1575
+ let traceSessionStartedAt = null
1576
+ let traceLog = [] // [{ tool, id, ts }]
1577
+ let traceWriteStream = null
1578
+
1579
+ function traceDirFor(root) { return path.join(root, HISTORY_DIR_NAME, TRACE_DIR_NAME) }
1580
+ function traceFileFor(root, sessionId) {
1581
+ return path.join(traceDirFor(root), `session-${sessionId}.jsonl`)
1582
+ }
1583
+
1584
+ function startTraceSession() {
1585
+ if (!currentRoot) return
1586
+ traceSessionId = Date.now()
1587
+ traceSessionStartedAt = traceSessionId
1588
+ traceLog = []
1589
+ closeTraceWriteStream()
1590
+ try {
1591
+ fs.mkdirSync(traceDirFor(currentRoot), { recursive: true })
1592
+ traceWriteStream = fs.createWriteStream(traceFileFor(currentRoot, traceSessionId), { flags: 'a' })
1593
+ // First line: session metadata
1594
+ traceWriteStream.write(JSON.stringify({
1595
+ type: 'meta', sessionId: traceSessionId, root: currentRoot, startedAt: traceSessionStartedAt,
1596
+ }) + '\n')
1597
+ } catch (e) {
1598
+ traceWriteStream = null
1599
+ }
1600
+ }
1601
+ function closeTraceWriteStream() {
1602
+ if (traceWriteStream) {
1603
+ try { traceWriteStream.end() } catch {}
1604
+ traceWriteStream = null
1605
+ }
1606
+ }
1607
+
1608
+ // Trust metadata for an AI session-trace event: was the queried/touched file
1609
+ // statically confident, or does it use dynamic/reflective/DI patterns the graph
1610
+ // can't fully resolve? Logging this per query lets a reviewer judge whether the
1611
+ // AI was working from complete data or known-incomplete data.
1612
+ function traceMetaFor(id) {
1613
+ const f = scanner && scanner.files && scanner.files.get(id)
1614
+ if (!f) return null
1615
+ const dyn = (f.dynamicPatterns || [])
1616
+ return { conf: f.confidence || 'high', dyn: dyn.length ? dyn : undefined }
1617
+ }
1618
+
1619
+ function emitTrace(tool, id, meta) {
1620
+ if (!id) return
1621
+ const ts = Date.now()
1622
+ // Auto-attach per-file trust meta (confidence / dynamic patterns) unless the
1623
+ // caller passed its own richer meta (e.g. blast impact stats).
1624
+ const ev = { tool, id, ts, ...(meta || traceMetaFor(id) || {}) }
1625
+ // In-memory (cap with sliding window)
1626
+ traceLog.push(ev)
1627
+ if (traceLog.length > TRACE_MEM_CAP) traceLog.splice(0, traceLog.length - TRACE_MEM_CAP)
1628
+ // Disk append (best-effort)
1629
+ if (traceWriteStream) {
1630
+ try { traceWriteStream.write(JSON.stringify(ev) + '\n') } catch {}
1631
+ }
1632
+ mainWindow?.webContents.send('control:trace', { ...ev })
1633
+ }
1634
+
1635
+ function listTraceSessions(root) {
1636
+ if (!root) return []
1637
+ const dir = traceDirFor(root)
1638
+ if (!fs.existsSync(dir)) return []
1639
+ const out = []
1640
+ for (const name of fs.readdirSync(dir)) {
1641
+ const m = name.match(/^session-(\d+)\.jsonl$/)
1642
+ if (!m) continue
1643
+ const sessionId = parseInt(m[1], 10)
1644
+ const full = path.join(dir, name)
1645
+ let stat, size = 0, eventCount = 0, endedAt = sessionId
1646
+ try { stat = fs.statSync(full); size = stat.size; endedAt = stat.mtimeMs } catch {}
1647
+ // Cheap line count for event count (subtract 1 for meta line)
1648
+ try {
1649
+ const data = fs.readFileSync(full, 'utf8')
1650
+ eventCount = Math.max(0, data.split('\n').filter((l) => l.trim()).length - 1)
1651
+ } catch {}
1652
+ out.push({
1653
+ sessionId, startedAt: sessionId, endedAt,
1654
+ eventCount, size,
1655
+ isCurrent: sessionId === traceSessionId,
1656
+ })
1657
+ }
1658
+ return out.sort((a, b) => b.startedAt - a.startedAt)
1659
+ }
1660
+
1661
+ function readTraceSession(root, sessionId) {
1662
+ if (!root) return null
1663
+ const f = traceFileFor(root, sessionId)
1664
+ if (!fs.existsSync(f)) return null
1665
+ let meta = null
1666
+ const events = []
1667
+ try {
1668
+ const data = fs.readFileSync(f, 'utf8')
1669
+ for (const line of data.split('\n')) {
1670
+ if (!line.trim()) continue
1671
+ try {
1672
+ const j = JSON.parse(line)
1673
+ if (j.type === 'meta') meta = j
1674
+ else events.push(j)
1675
+ } catch {}
1676
+ }
1677
+ } catch { return null }
1678
+ return { sessionId, meta, events, eventCount: events.length }
1679
+ }
1680
+
1681
+ // Compute stats over an event array. Used by /trace/stats and by the
1682
+ // session-detail endpoint.
1683
+ function computeTraceStats(events) {
1684
+ const byTool = {}
1685
+ const byFile = new Map()
1686
+ let firstAt = null, lastAt = null
1687
+ for (const e of events) {
1688
+ byTool[e.tool] = (byTool[e.tool] || 0) + 1
1689
+ byFile.set(e.id, (byFile.get(e.id) || 0) + 1)
1690
+ if (firstAt === null || e.ts < firstAt) firstAt = e.ts
1691
+ if (lastAt === null || e.ts > lastAt) lastAt = e.ts
1692
+ }
1693
+ const topFiles = [...byFile.entries()]
1694
+ .map(([id, count]) => ({ id, count }))
1695
+ .sort((a, b) => b.count - a.count)
1696
+ .slice(0, 20)
1697
+ // Time histogram — 20 buckets across [firstAt, lastAt]
1698
+ let timeline = []
1699
+ if (firstAt !== null && lastAt !== null && lastAt > firstAt) {
1700
+ const buckets = 20
1701
+ timeline = Array(buckets).fill(0)
1702
+ const span = lastAt - firstAt
1703
+ for (const e of events) {
1704
+ const idx = Math.min(buckets - 1, Math.floor((e.ts - firstAt) / span * buckets))
1705
+ timeline[idx]++
1706
+ }
1707
+ }
1708
+ return {
1709
+ eventCount: events.length,
1710
+ fileCount: byFile.size,
1711
+ byTool, topFiles, timeline,
1712
+ firstAt, lastAt,
1713
+ durationMs: (firstAt && lastAt) ? lastAt - firstAt : 0,
1714
+ }
1715
+ }
1716
+
1717
+ // ─── lib/control-server delegate (Stage 1+2 endpoints) ────────
1718
+ // Lazily create a control-server instance from the shared lib module.
1719
+ // Used as a fallthrough for new endpoints (safety/bundle/env/suggest/
1720
+ // feature/preflight/schema/url/secrets) so the desktop app exposes
1721
+ // them too, without duplicating their logic here. Existing endpoints
1722
+ // remain handled by this file's own router below — append-only.
1723
+ const { createControlServer: _libCreateControlServer } = require('../packages/core/lib/control-server.cjs')
1724
+ const { createLogger } = require('../packages/core/lib/logger.cjs')
1725
+
1726
+ // Structured logger for the main process. Writes NDJSON to
1727
+ // ~/.codesynapt/audit/main-YYYY-MM-DD.jsonl. Level info+ → file; warn+ → stderr.
1728
+ const _logFile = path.join(app.getPath('home'), '.codesynapt', 'audit',
1729
+ `main-${new Date().toISOString().slice(0,10)}.jsonl`)
1730
+ const log = createLogger({ file: _logFile, module: 'main', level: 'info', echoStderr: 'warn' })
1731
+
1732
+ // ─── Search worker (isolated from main event loop) ─────────────
1733
+ // Persistent worker (reused across searches) so its in-memory mtime cache
1734
+ // makes the 2nd+ search fast. The worker is rebuilt only when the scanner
1735
+ // is swapped (different project root). Files > 5 MB are pre-skipped to
1736
+ // avoid the libuv-thread-stall that previously plagued worker reuse.
1737
+ const { Worker } = require('worker_threads')
1738
+ let _searchWorker = null
1739
+ let _searchWorkerReady = false
1740
+ let _searchScannerRef = null
1741
+ let _searchInFlight = null // { reqId, resolve, reject, timer }
1742
+ let _searchReqCounter = 0
1743
+
1744
+ function _teardownSearchWorker() {
1745
+ if (_searchWorker) { try { _searchWorker.terminate() } catch {} }
1746
+ _searchWorker = null
1747
+ _searchWorkerReady = false
1748
+ if (_searchInFlight) {
1749
+ clearTimeout(_searchInFlight.timer)
1750
+ _searchInFlight.reject(new Error('worker recycled'))
1751
+ _searchInFlight = null
1752
+ }
1753
+ }
1754
+
1755
+ function _ensureSearchWorker() {
1756
+ if (_searchWorker && _searchScannerRef === scanner) return _searchWorker
1757
+ _teardownSearchWorker()
1758
+ const workerPath = path.resolve(__dirname, '..', 'packages', 'core', 'lib', 'search-worker.cjs')
1759
+ const w = new Worker(workerPath)
1760
+ _searchScannerRef = scanner
1761
+ w.on('message', (msg) => {
1762
+ if (msg.type === 'ready') { _searchWorkerReady = true; return }
1763
+ if (!_searchInFlight) return
1764
+ const inflight = _searchInFlight
1765
+ _searchInFlight = null
1766
+ clearTimeout(inflight.timer)
1767
+ if (msg.type === 'result') inflight.resolve(msg.payload)
1768
+ else inflight.reject(new Error(msg.error || 'worker error'))
1769
+ })
1770
+ w.on('error', (e) => {
1771
+ if (_searchInFlight) { clearTimeout(_searchInFlight.timer); _searchInFlight.reject(e); _searchInFlight = null }
1772
+ _searchWorker = null
1773
+ _searchWorkerReady = false
1774
+ })
1775
+ w.on('exit', () => {
1776
+ _searchWorker = null
1777
+ _searchWorkerReady = false
1778
+ if (_searchInFlight) {
1779
+ clearTimeout(_searchInFlight.timer)
1780
+ _searchInFlight.reject(new Error('worker exited unexpectedly'))
1781
+ _searchInFlight = null
1782
+ }
1783
+ })
1784
+ // Keep worker's cache in sync with scanner
1785
+ if (scanner) {
1786
+ scanner.on('file-changed', ({ id }) => { try { w.postMessage({ type: 'invalidate', id }) } catch {} })
1787
+ scanner.on('file-removed', ({ id }) => { try { w.postMessage({ type: 'invalidate', id }) } catch {} })
1788
+ }
1789
+ _searchWorker = w
1790
+ return w
1791
+ }
1792
+
1793
+ function _searchInWorker(opts, timeoutMs = 60000) {
1794
+ return new Promise((resolve, reject) => {
1795
+ if (_searchInFlight) return reject(new Error('worker busy — another search is in flight'))
1796
+ if (!scanner) return reject(new Error('scanner not ready'))
1797
+ const w = _ensureSearchWorker()
1798
+ const reqId = ++_searchReqCounter
1799
+ const files = [...scanner.files.values()].map((f) => ({ id: f.id, absPath: f.absPath }))
1800
+ const timer = setTimeout(() => {
1801
+ if (!_searchInFlight || _searchInFlight.reqId !== reqId) return
1802
+ _searchInFlight = null
1803
+ // Worker may be stuck — recycle so future requests are clean
1804
+ _teardownSearchWorker()
1805
+ reject(new Error(`worker timeout ${timeoutMs}ms`))
1806
+ }, timeoutMs)
1807
+ _searchInFlight = { reqId, resolve, reject, timer }
1808
+ // If worker hasn't fired 'ready' yet, queue a one-shot send
1809
+ if (_searchWorkerReady) {
1810
+ w.postMessage({ type: 'search', id: reqId, files, ...opts })
1811
+ } else {
1812
+ const onReady = (msg) => {
1813
+ if (msg.type !== 'ready') return
1814
+ w.off('message', onReady)
1815
+ w.postMessage({ type: 'search', id: reqId, files, ...opts })
1816
+ }
1817
+ w.on('message', onReady)
1818
+ }
1819
+ })
1820
+ }
1821
+ let _libControlHandler = null
1822
+ let _libScannerRef = null // invalidate when scanner is swapped
1823
+ function _ensureLibHandler() {
1824
+ if (!scanner) return null
1825
+ if (_libControlHandler && _libScannerRef === scanner) return _libControlHandler
1826
+ _libScannerRef = scanner
1827
+ const lib = _libCreateControlServer({
1828
+ scanner,
1829
+ getCurrentRoot: () => currentRoot,
1830
+ onBlast: (p) => mainWindow?.webContents.send('control:blast', p),
1831
+ onFocus: (id) => mainWindow?.webContents.send('control:focus', { id }),
1832
+ onOpen: (id) => mainWindow?.webContents.send('control:open', { id }),
1833
+ authToken: process.env.CS_AUTH_TOKEN || null,
1834
+ auditLogDir: path.join(app.getPath('home'), '.codesynapt', 'audit'),
1835
+ })
1836
+ _libControlHandler = lib.handleControlRequest
1837
+ return _libControlHandler
1838
+ }
1839
+ const _LIB_ENDPOINTS = new Set([
1840
+ 'safety', 'bundle', 'env', 'suggest', 'feature', 'preflight',
1841
+ 'schema', 'url', 'secrets',
1842
+ ])
1843
+
1844
+ async function handleControlRequest(req, res) {
1845
+ // DNS-rebinding defense: reject Host headers that aren't loopback.
1846
+ const hostHeader = (req.headers.host || '').split(':')[0].toLowerCase()
1847
+ if (hostHeader !== '127.0.0.1' && hostHeader !== 'localhost' && hostHeader !== '[::1]') {
1848
+ return writeJson(res, 403, { error: 'forbidden host: ' + hostHeader })
1849
+ }
1850
+ if (req.method === 'OPTIONS') {
1851
+ res.writeHead(204, {
1852
+ 'Access-Control-Allow-Origin': 'null',
1853
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
1854
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1855
+ })
1856
+ return res.end()
1857
+ }
1858
+
1859
+ const url = new URL(req.url, `http://${req.headers.host}`)
1860
+ const parts = url.pathname.split('/').filter(Boolean)
1861
+ const [seg0, ...rest] = parts
1862
+ const idFromRest = () => decodeURIComponent(rest.join('/'))
1863
+
1864
+ // Delegate new endpoints to lib/control-server (Stage 1+2 features)
1865
+ if (_LIB_ENDPOINTS.has(seg0)) {
1866
+ const lib = _ensureLibHandler()
1867
+ if (lib) return lib(req, res)
1868
+ // If scanner isn't ready yet, fall through to 503 below
1869
+ }
1870
+ const traceId = () => { const id = idFromRest(); emitTrace(seg0, id); return id }
1871
+
1872
+ try {
1873
+ if (req.method === 'GET' && parts.length === 0) {
1874
+ return writeJson(res, 200, {
1875
+ name: 'codesynapt',
1876
+ endpoints: [
1877
+ 'GET /health', 'GET /graph', 'GET /node/:id', 'GET /file/:id',
1878
+ 'GET /deps/:id', 'GET /users/:id', 'GET /find?q=', 'GET /history/:id',
1879
+ 'GET /packages', 'GET /package/:name', 'GET /package-graph',
1880
+ 'GET /legacy?type=orphan|path|filename|duplicate',
1881
+ 'GET /trace', 'GET /trace/stats', 'GET /trace/sessions', 'GET /trace/session/:id',
1882
+ 'POST /trace/clear', 'POST /trace/export?path=',
1883
+ 'POST /focus/:id', 'POST /open/:id', 'POST /restore/:id?ts=',
1884
+ 'POST /write/:id', 'POST /edit/:id',
1885
+ ],
1886
+ })
1887
+ }
1888
+
1889
+ if (req.method === 'GET' && seg0 === 'health') {
1890
+ // Edge count is split into code-structure edges and asset edges
1891
+ // (HTML→image/script/style links), so a docs-heavy repo can't
1892
+ // make health stats lie about the code graph size.
1893
+ let codeEdges = 0, assetEdges = 0
1894
+ if (scanner) for (const e of scanner.edges) {
1895
+ if (e.kind === 'asset' || e.k === 'asset') assetEdges++
1896
+ else codeEdges++
1897
+ }
1898
+ return writeJson(res, 200, {
1899
+ ok: true,
1900
+ root: currentRoot,
1901
+ fileCount: scanner ? scanner.files.size : 0,
1902
+ edgeCount: codeEdges,
1903
+ assetEdgeCount: assetEdges,
1904
+ historyEnabled,
1905
+ })
1906
+ }
1907
+
1908
+ if (!scanner) {
1909
+ // POST /load boots the scanner from scratch — let it through
1910
+ // the 503 gate. Headless instances (CS_HEADLESS=1) never call
1911
+ // startScanner via did-finish-load, so /load is the only path
1912
+ // to get a scanner running.
1913
+ if (!(req.method === 'POST' && seg0 === 'load')) {
1914
+ return writeJson(res, 503, { error: 'no folder loaded' })
1915
+ }
1916
+ }
1917
+
1918
+ // ─── Symbol mode (codegraph-equivalent layer) ────────────────
1919
+ // First call builds the symbol graph against the current file set;
1920
+ // subsequent calls hit the in-memory cache. POST /symbol/scan forces
1921
+ // a rebuild even if one exists.
1922
+ if (seg0 === 'symbol') {
1923
+ const sub = rest[0] || '' // 'summary' | 'find' | 'callers' | …
1924
+ // Ensure the symbol graph is built before serving any query.
1925
+ const forceRebuild = (req.method === 'POST' && sub === 'scan')
1926
+ if (forceRebuild || !symbolGraph) {
1927
+ if (!_symbolBuilding) {
1928
+ _symbolBuilding = (async () => {
1929
+ // Optional disk cache for big projects. Opt-in via env var
1930
+ // CS_SYMBOL_CACHE=1; defaults off so the in-memory speed
1931
+ // moat stays the default. Cache key is sha-of-root-path +
1932
+ // newest-file-mtime. If anything in the repo has changed
1933
+ // since the cache was written, we rebuild.
1934
+ const cacheEnabled = !forceRebuild && process.env.CS_SYMBOL_CACHE === '1'
1935
+ const cacheDir = path.join(os.homedir(), '.codesynapt', 'symbol-cache')
1936
+ const cacheKey = require('crypto').createHash('sha1').update(currentRoot || '').digest('hex')
1937
+ const cachePath = path.join(cacheDir, cacheKey + '.json')
1938
+ if (cacheEnabled) {
1939
+ try {
1940
+ if (fs.existsSync(cachePath)) {
1941
+ const cached = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
1942
+ // Newest mtime in the file set must be older than the
1943
+ // cache mtime for it to be valid.
1944
+ let newest = 0
1945
+ for (const f of scanner.files.values()) {
1946
+ try { const t = fs.statSync(f.absPath).mtimeMs; if (t > newest) newest = t } catch {}
1947
+ }
1948
+ const cacheMtime = fs.statSync(cachePath).mtimeMs
1949
+ if (newest > 0 && cacheMtime >= newest) {
1950
+ const g = new SymbolGraph()
1951
+ for (const n of cached.nodes) g.addNode(n)
1952
+ for (const e of cached.edges) g.addEdge(e)
1953
+ g.fileCount = cached.fileCount
1954
+ g.builtAt = cached.builtAt
1955
+ g.scanMs = 0
1956
+ try { g.computeReachability(isPublicEntry) } catch {}
1957
+ symbolGraph = g
1958
+ _symbolBuilding = null
1959
+ return g
1960
+ }
1961
+ }
1962
+ } catch {}
1963
+ }
1964
+ const g = new SymbolGraph()
1965
+ const entries = [...scanner.files.values()]
1966
+ .filter((f) => f.absPath && f.ext)
1967
+ .map((f) => ({ id: f.id, absPath: f.absPath, ext: f.ext }))
1968
+ // tsc mode: build the TS Program once over every JS/TS
1969
+ // file in the project before the per-file parser loop
1970
+ // starts. Cached by rootAbs; cleared on project swap.
1971
+ // If loadProgramFor refuses (file count above the memory
1972
+ // cap, or typescript not installed), we re-register the
1973
+ // babel parser so .ts/.tsx files still get symbols.
1974
+ if (SYMBOL_PARSER_MODE === 'tsc' && _tscParserModule?.isAvailable?.()) {
1975
+ try {
1976
+ const prog = _tscParserModule.loadProgramFor(currentRoot, entries.map((e) => e.id))
1977
+ if (!prog) {
1978
+ console.warn('[symbol] tsc Program refused — falling back to babel for ts/tsx/js/jsx')
1979
+ for (const ext of ['ts','tsx','js','jsx','mjs','cjs']) {
1980
+ registerParser([ext], jsSymbolParser)
1981
+ }
1982
+ }
1983
+ } catch (e) {
1984
+ console.error('[symbol] tsc Program init failed:', e.message)
1985
+ for (const ext of ['ts','tsx','js','jsx','mjs','cjs']) {
1986
+ registerParser([ext], jsSymbolParser)
1987
+ }
1988
+ }
1989
+ }
1990
+ // Feed file-mode imports to the symbol graph so call
1991
+ // resolution can prefer targets in files the caller
1992
+ // actually imports (Phase 2-B cross-file resolver).
1993
+ // Asset edges (HTML→jQuery etc) aren't real imports.
1994
+ const fileImports = new Map()
1995
+ const reexports = new Map()
1996
+ for (const e of scanner.edges) {
1997
+ const kind = e.k || e.kind
1998
+ if (kind === 'asset') continue
1999
+ if (!fileImports.has(e.s)) fileImports.set(e.s, new Set())
2000
+ fileImports.get(e.s).add(e.t)
2001
+ if (kind === 'reexport') {
2002
+ if (!reexports.has(e.s)) reexports.set(e.s, new Set())
2003
+ reexports.get(e.s).add(e.t)
2004
+ }
2005
+ }
2006
+ // Re-export chain: `import { X } from './barrel'` where
2007
+ // barrel does `export * from './foo'` should resolve X
2008
+ // against foo too. BFS-expand each file's imports through
2009
+ // the re-export graph so chains of any depth are reachable.
2010
+ const reachCache = new Map()
2011
+ function reExportReach(start) {
2012
+ if (reachCache.has(start)) return reachCache.get(start)
2013
+ const out = new Set()
2014
+ const stack = [start]
2015
+ const visited = new Set()
2016
+ while (stack.length) {
2017
+ const cur = stack.pop()
2018
+ if (visited.has(cur)) continue
2019
+ visited.add(cur)
2020
+ out.add(cur)
2021
+ const next = reexports.get(cur)
2022
+ if (next) for (const n of next) stack.push(n)
2023
+ }
2024
+ reachCache.set(start, out)
2025
+ return out
2026
+ }
2027
+ for (const [src, set] of fileImports) {
2028
+ for (const target of [...set]) {
2029
+ const reach = reExportReach(target)
2030
+ for (const r of reach) set.add(r)
2031
+ }
2032
+ }
2033
+ await g.build(entries, fileImports)
2034
+ // ─── DB schema models → symbol nodes ─────────────────
2035
+ // file-mode already extracts Prisma / Drizzle / Mongoose /
2036
+ // TypeORM / SQLAlchemy model declarations. We register
2037
+ // each model name as a `model` symbol so the normal
2038
+ // resolver picks up `prisma.user.findMany()`,
2039
+ // `db.User.create(...)`, etc., and surfaces them in
2040
+ // `cs_symbol_explore` answers.
2041
+ for (const f of scanner.files.values()) {
2042
+ if (!f.dbModels?.length) continue
2043
+ for (const model of f.dbModels) {
2044
+ const id = `${f.id}#${model.name}@0`
2045
+ if (g.nodes.has(id)) continue
2046
+ g.addNode({
2047
+ id,
2048
+ name: model.name,
2049
+ qualifiedName: model.name,
2050
+ kind: 'model',
2051
+ file: f.id,
2052
+ startLine: 1,
2053
+ endLine: 1,
2054
+ signature: `${model.kind} ${model.name}`
2055
+ + (model.tableName ? ` (table=${model.tableName})` : ''),
2056
+ doc: (model.fields || []).map((fld) => fld.name).join(', '),
2057
+ exported: true,
2058
+ })
2059
+ }
2060
+ }
2061
+ // ─── Test ↔ source pairing ───────────────────────────
2062
+ // Detect common test-file naming conventions and emit
2063
+ // `tests` edges from test symbols to their target source
2064
+ // symbol (same name, or whose name is a substring).
2065
+ const findSourcePair = (fileId) => {
2066
+ let m
2067
+ // foo.test.ts → foo.ts / foo.spec.tsx → foo.tsx
2068
+ if ((m = fileId.match(/^(.*)\.(test|spec)\.(\w+)$/))) {
2069
+ return m[1] + '.' + m[3]
2070
+ }
2071
+ // src/foo/__tests__/Bar.test.ts → src/foo/Bar.ts
2072
+ if ((m = fileId.match(/^(.*?)\/__tests__\/(.+?)\.(test|spec)\.(\w+)$/))) {
2073
+ return m[1] + '/' + m[2] + '.' + m[4]
2074
+ }
2075
+ // tests/path/Bar.test.ts → src/path/Bar.ts (best-effort)
2076
+ if ((m = fileId.match(/^tests?\/(.+?)\.(test|spec)\.(\w+)$/))) {
2077
+ const candidate = 'src/' + m[1] + '.' + m[3]
2078
+ return candidate
2079
+ }
2080
+ // Python: tests/test_foo.py → foo.py / src/foo.py
2081
+ if ((m = fileId.match(/^(.*?\/)?tests?\/test_(.+)\.py$/))) {
2082
+ return (m[1] || '') + m[2] + '.py'
2083
+ }
2084
+ if ((m = fileId.match(/^(.*\/)?test_(.+)\.py$/))) {
2085
+ return (m[1] || '') + m[2] + '.py'
2086
+ }
2087
+ // Go: foo_test.go → foo.go
2088
+ if ((m = fileId.match(/^(.+)_test\.go$/))) return m[1] + '.go'
2089
+ // Rust: tests/foo.rs → src/foo.rs
2090
+ if ((m = fileId.match(/^tests\/(.+)\.rs$/))) return 'src/' + m[1] + '.rs'
2091
+ return null
2092
+ }
2093
+ for (const f of scanner.files.values()) {
2094
+ const srcFileId = findSourcePair(f.id)
2095
+ if (!srcFileId || !scanner.files.has(srcFileId)) continue
2096
+ const testSyms = g.byFile.get(f.id)
2097
+ const srcSyms = g.byFile.get(srcFileId)
2098
+ if (!testSyms || !srcSyms) continue
2099
+ for (const testId of testSyms) {
2100
+ const testSym = g.nodes.get(testId)
2101
+ if (!testSym) continue
2102
+ const tn = testSym.name.toLowerCase()
2103
+ for (const srcId of srcSyms) {
2104
+ const srcSym = g.nodes.get(srcId)
2105
+ if (!srcSym) continue
2106
+ const sn = srcSym.name.toLowerCase()
2107
+ // Exact match wins; otherwise substring with a length
2108
+ // floor to avoid `t` / `it` matching everything.
2109
+ if (tn === sn || (sn.length >= 4 && tn.includes(sn))) {
2110
+ g.addEdge({
2111
+ source: testId, target: srcId, kind: 'tests',
2112
+ line: testSym.startLine,
2113
+ })
2114
+ break // one source-symbol target per test symbol is enough
2115
+ }
2116
+ }
2117
+ }
2118
+ }
2119
+ // ─── Persist symbol graph to cache (opt-in) ──────────
2120
+ if (cacheEnabled) {
2121
+ try {
2122
+ fs.mkdirSync(cacheDir, { recursive: true })
2123
+ const payload = {
2124
+ nodes: [...g.nodes.values()],
2125
+ edges: g.edges,
2126
+ fileCount: g.fileCount,
2127
+ builtAt: g.builtAt,
2128
+ }
2129
+ fs.writeFileSync(cachePath, JSON.stringify(payload))
2130
+ } catch {}
2131
+ }
2132
+ // ─── Route → handler edges ───────────────────────────
2133
+ // Walk every file's `routes` list (extracted by file
2134
+ // mode) and link the route to the named handler symbol.
2135
+ // codegraph doesn't do this — its index is method/class
2136
+ // only, with no notion of HTTP path. Ours does.
2137
+ for (const f of scanner.files.values()) {
2138
+ if (!f.routes?.length) continue
2139
+ for (const route of f.routes) {
2140
+ if (!route.handler) continue
2141
+ const handlerNode = g.resolveCall(f.id, route.handler, { allowAny: true })
2142
+ if (!handlerNode) continue
2143
+ g.addEdge({
2144
+ source: 'route:' + (route.method || 'ANY') + ' ' + route.path,
2145
+ target: handlerNode.id,
2146
+ kind: 'route',
2147
+ line: 0,
2148
+ meta: { method: route.method, path: route.path, definedIn: f.id },
2149
+ })
2150
+ }
2151
+ }
2152
+ // Reachability pass — BFS from every detected public
2153
+ // entry. Lets explore tag `unreachable` results as a
2154
+ // weak dead-code hint without paying the BFS cost per
2155
+ // query. Cheap: O(V+E) over the symbol graph.
2156
+ try { g.computeReachability(isPublicEntry) } catch (e) {
2157
+ console.warn('[symbol] reachability pass failed:', e.message)
2158
+ }
2159
+ // Semantic embedding pass — fired without await. The
2160
+ // build returns immediately; embeddings populate in the
2161
+ // background. /symbol/explore checks `g._embedded`
2162
+ // before reranking, so early queries still get fast
2163
+ // keyword-only answers, and later queries get the
2164
+ // semantic upgrade once the index finishes (~1 ms per
2165
+ // symbol on MiniLM-L6, so ~10 s on a 10k-symbol repo
2166
+ // and ~45 s on django's 43k).
2167
+ //
2168
+ // Opt-out via CS_EMBEDDING=0 for users on memory-tight
2169
+ // boxes (the index adds ~200 MB to RSS).
2170
+ if (process.env.CS_EMBEDDING !== '0') {
2171
+ const embedding = require('../packages/core/lib/embedding.cjs')
2172
+ g.embedAllSymbols(embedding.embedBatch).then((ok) => {
2173
+ if (ok) console.log(`[symbol] embeddings ready: ${g.nodes.size} symbols in ${g.embedMs}ms`)
2174
+ }).catch((e) => {
2175
+ console.warn('[symbol] embedding pass failed:', e.message)
2176
+ })
2177
+ }
2178
+ symbolGraph = g
2179
+ _symbolBuilding = null
2180
+ return g
2181
+ })()
2182
+ }
2183
+ await _symbolBuilding
2184
+ }
2185
+ const g = symbolGraph
2186
+
2187
+ if (req.method === 'GET' && (sub === '' || sub === 'summary')) {
2188
+ return writeJson(res, 200, withMeta(g.stats()))
2189
+ }
2190
+ if (req.method === 'GET' && sub === 'find') {
2191
+ const q = url.searchParams.get('q') || ''
2192
+ const limit = Math.min(200, parseInt(url.searchParams.get('limit') || '50', 10))
2193
+ const matches = g.findByName(q, limit)
2194
+ return writeJson(res, 200, withMeta({ query: q, matches }))
2195
+ }
2196
+ if (req.method === 'GET' && sub === 'node' && rest[1]) {
2197
+ const id = decodeURIComponent(rest.slice(1).join('/'))
2198
+ const node = g.nodes.get(id)
2199
+ if (!node) return writeJson(res, 404, { error: 'symbol not found', id })
2200
+ // Pull source between startLine and endLine
2201
+ let source = ''
2202
+ try {
2203
+ const filePath = path.join(currentRoot, node.file)
2204
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n')
2205
+ source = lines.slice(node.startLine - 1, node.endLine).join('\n')
2206
+ if (source.length > 4000) source = source.slice(0, 4000) + '\n…'
2207
+ } catch {}
2208
+ return writeJson(res, 200, withMeta({ ...node, source }))
2209
+ }
2210
+ if (req.method === 'GET' && sub === 'callers' && rest[1]) {
2211
+ const id = decodeURIComponent(rest.slice(1).join('/'))
2212
+ return writeJson(res, 200, withMeta({ id, callers: g.callersOf(id) }))
2213
+ }
2214
+ if (req.method === 'GET' && sub === 'callees' && rest[1]) {
2215
+ const id = decodeURIComponent(rest.slice(1).join('/'))
2216
+ return writeJson(res, 200, withMeta({ id, callees: g.calleesOf(id) }))
2217
+ }
2218
+ if (req.method === 'GET' && sub === 'explore') {
2219
+ const q = url.searchParams.get('q') || ''
2220
+ const budget = parseInt(url.searchParams.get('budget') || '8000', 10)
2221
+ // /symbol/explore now returns a single response shape: the
2222
+ // classify (grouped) one. The older modes — default (ranked
2223
+ // list), structural, behavioral, dataflow — were retired
2224
+ // after a 99-measurement bench found zero scenarios where
2225
+ // they out-performed classify. Clients that still send a
2226
+ // `mode=...` parameter get a classify response with a `note`
2227
+ // so they can update without breaking.
2228
+ const requestedMode = (url.searchParams.get('mode') || 'classify').toLowerCase()
2229
+ const payload = await buildClassifyResponse(g, q, budget)
2230
+ if (requestedMode !== 'classify') {
2231
+ payload.note = `mode "${requestedMode}" is no longer supported — returning classify shape`
2232
+ }
2233
+ return writeJson(res, 200, withMeta(payload))
2234
+ }
2235
+ if (req.method === 'POST' && sub === 'scan') {
2236
+ return writeJson(res, 200, withMeta(g.stats()))
2237
+ }
2238
+ return writeJson(res, 404, { error: 'unknown symbol endpoint', path: url.pathname })
2239
+ }
2240
+
2241
+ if (req.method === 'GET' && seg0 === 'summary') {
2242
+ const s = buildSummaryCached()
2243
+ if (!s) return writeJson(res, 503, { error: 'no folder loaded' })
2244
+ return writeJson(res, 200, withMeta(s))
2245
+ }
2246
+ if (req.method === 'GET' && seg0 === 'graph') {
2247
+ // filter → sort → paginate. Default sort is mass:desc so a bare
2248
+ // `limit=N` returns the N most-imported files (genuinely useful),
2249
+ // not insertion-order garbage. Pass sort=insertion to opt out.
2250
+ const data = getGraphState()
2251
+ const limit = parseInt(url.searchParams.get('limit') || '0', 10)
2252
+ const offset = parseInt(url.searchParams.get('offset') || '0', 10)
2253
+ const extFilter = url.searchParams.get('ext')
2254
+ const minMass = parseInt(url.searchParams.get('minMass') || '0', 10)
2255
+ const sort = url.searchParams.get('sort') || 'mass:desc'
2256
+
2257
+ // Incoming map (computed once if mass involved in filter or sort)
2258
+ let inc = null
2259
+ const needsInc = sort.startsWith('mass') || minMass > 0
2260
+ if (needsInc) {
2261
+ inc = new Map()
2262
+ for (const e of scanner.edges) inc.set(e.t, (inc.get(e.t) || 0) + 1)
2263
+ }
2264
+
2265
+ let files = data.files.slice()
2266
+ if (extFilter) files = files.filter((f) => f.ext === extFilter)
2267
+ if (minMass > 0) files = files.filter((f) => (inc.get(f.id) || 0) >= minMass)
2268
+
2269
+ // Sort
2270
+ if (sort !== 'insertion') {
2271
+ const [key, dirRaw] = sort.split(':')
2272
+ const dir = dirRaw === 'asc' ? 1 : -1
2273
+ const getter = key === 'mass' ? ((f) => inc.get(f.id) || 0)
2274
+ : key === 'size' ? ((f) => f.size)
2275
+ : key === 'loc' ? ((f) => f.loc)
2276
+ : key === 'id' ? null
2277
+ : null
2278
+ if (getter) files.sort((a, b) => dir * (getter(a) - getter(b)))
2279
+ else if (key === 'id') files.sort((a, b) => dir * a.id.localeCompare(b.id))
2280
+ // else: unknown sort key — silently keep filter order
2281
+ }
2282
+
2283
+ const totalAvailable = files.length
2284
+ const sliced = limit > 0 ? files.slice(offset, offset + limit) : files
2285
+ return writeJson(res, 200, withMeta(
2286
+ { root: data.root, files: sliced, edges: data.edges },
2287
+ { totalAvailable, returned: sliced.length, offset, limit: limit || sliced.length,
2288
+ sort, truncated: limit > 0 && (offset + limit) < totalAvailable }
2289
+ ))
2290
+ }
2291
+ if (req.method === 'GET' && seg0 === 'node' && rest.length > 0) {
2292
+ const id = traceId()
2293
+ const node = findNode(id)
2294
+ if (!node) return writeJson(res, 404, { error: 'not found' })
2295
+ return writeJson(res, 200, withMeta({
2296
+ ...node,
2297
+ imports: getDeps(id),
2298
+ importedBy: getUsers(id),
2299
+ }))
2300
+ }
2301
+ if (req.method === 'POST' && seg0 === 'refresh') {
2302
+ if (rest.length === 0) return writeJson(res, 400, { error: 'usage: POST /refresh/:id' })
2303
+ const id = idFromRest()
2304
+ try {
2305
+ const absPath = path.join(currentRoot, id)
2306
+ if (!isInsideRoot(currentRoot, absPath)) return writeJson(res, 400, { error: 'outside root' })
2307
+ if (!fs.existsSync(absPath)) {
2308
+ // File deleted — drop from graph
2309
+ if (scanner.files.delete(id)) { scanner.rebuildEdges(); scanner.emitSnapshot() }
2310
+ return writeJson(res, 200, withMeta({ ok: true, action: 'removed', id }))
2311
+ }
2312
+ // Force re-parse via scanner internals
2313
+ const file = scanner.parseOne(absPath)
2314
+ if (!file) return writeJson(res, 500, { error: 'parse failed' })
2315
+ scanner.files.set(file.id, file)
2316
+ scanner.rebuildEdges()
2317
+ scanner.emitSnapshot()
2318
+ return writeJson(res, 200, withMeta({ ok: true, action: 'refreshed', id }))
2319
+ } catch (e) { return writeJson(res, 500, { error: e.message }) }
2320
+ }
2321
+ if (req.method === 'GET' && seg0 === 'file' && rest.length > 0) {
2322
+ const id = traceId()
2323
+ const full = path.join(currentRoot, id)
2324
+ if (!isInsideRoot(currentRoot, full)) return writeJson(res, 400, { error: 'outside root' })
2325
+ try {
2326
+ const stat = fs.statSync(full)
2327
+ if (!stat.isFile()) return writeJson(res, 404, { error: 'not a file' })
2328
+ if (stat.size > 2_000_000) return writeJson(res, 413, { error: 'file too large', size: stat.size })
2329
+ return writeJson(res, 200, { id, content: fs.readFileSync(full, 'utf8') })
2330
+ } catch (e) { return writeJson(res, 500, { error: e.message }) }
2331
+ }
2332
+ if (req.method === 'GET' && seg0 === 'deps' && rest.length > 0) {
2333
+ return writeJson(res, 200, getDeps(traceId()))
2334
+ }
2335
+ if (req.method === 'GET' && seg0 === 'users' && rest.length > 0) {
2336
+ return writeJson(res, 200, getUsers(traceId()))
2337
+ }
2338
+ if (req.method === 'GET' && seg0 === 'find') {
2339
+ return writeJson(res, 200, searchFiles(url.searchParams.get('q') || ''))
2340
+ }
2341
+ if (req.method === 'GET' && seg0 === 'search') {
2342
+ // Full-text search across all tracked files (content scan).
2343
+ // Different from /find which only matches file IDs.
2344
+ // Runs in a worker_thread isolated from this event loop.
2345
+ const q = url.searchParams.get('q')
2346
+ if (!q) return writeJson(res, 400, { error: 'q (query) is required' })
2347
+ if (!scanner || !scanner.initialScanComplete) {
2348
+ const fileCount = scanner ? scanner.files.size : 0
2349
+ return writeJson(res, 503, {
2350
+ error: 'scan in progress',
2351
+ fileCount,
2352
+ retryAfterMs: 2000,
2353
+ hint: 'Initial scan still running. Try again in a couple of seconds; /health will keep increasing fileCount.',
2354
+ })
2355
+ }
2356
+ ;(async () => {
2357
+ try {
2358
+ const result = await _searchInWorker({
2359
+ q,
2360
+ regex: url.searchParams.get('regex') === '1' || url.searchParams.get('regex') === 'true',
2361
+ caseSensitive: url.searchParams.get('case') === '1' || url.searchParams.get('case') === 'true',
2362
+ max: parseInt(url.searchParams.get('max') || '100', 10),
2363
+ maxPerFile: parseInt(url.searchParams.get('maxPerFile') || '10', 10),
2364
+ })
2365
+ writeJson(res, 200, withMeta(result))
2366
+ } catch (e) {
2367
+ const msg = e.message || String(e)
2368
+ if (msg.includes('busy')) return writeJson(res, 503, { error: msg, retryAfterMs: 1000 })
2369
+ writeJson(res, 500, { error: msg })
2370
+ }
2371
+ })().catch((e) => writeJson(res, 500, { error: e.message }))
2372
+ return
2373
+ }
2374
+ if (req.method === 'GET' && seg0 === 'external') {
2375
+ return writeJson(res, 200, getExternalUrls())
2376
+ }
2377
+ if (req.method === 'GET' && seg0 === 'timeline') {
2378
+ buildTimeline().then((data) => writeJson(res, 200, data))
2379
+ .catch((e) => writeJson(res, 500, { error: e.message }))
2380
+ return
2381
+ }
2382
+ if (req.method === 'GET' && seg0 === 'tour') {
2383
+ const t = buildTour()
2384
+ if (!t) return writeJson(res, 503, { error: 'no folder loaded' })
2385
+ return writeJson(res, 200, t)
2386
+ }
2387
+ if (req.method === 'GET' && seg0 === 'changes' && rest.length === 0) {
2388
+ return writeJson(res, 200, listSessionChanges())
2389
+ }
2390
+ if (req.method === 'GET' && seg0 === 'changes' && rest.length > 0) {
2391
+ const id = idFromRest()
2392
+ const d = getChangeDiff(id)
2393
+ if (!d) return writeJson(res, 404, { error: 'no change recorded for this file' })
2394
+ return writeJson(res, 200, d)
2395
+ }
2396
+ if (req.method === 'GET' && seg0 === 'blast' && rest.length > 0) {
2397
+ const id = idFromRest()
2398
+ const depth = Math.max(1, Math.min(10, parseInt(url.searchParams.get('depth') || '3', 10)))
2399
+ const dir = url.searchParams.get('dir') === 'deps' ? 'deps' : 'users'
2400
+ const r = computeBlastRadius(id, depth, dir)
2401
+ if (!r) { emitTrace('blast', id); return writeJson(res, 404, { error: 'not found' }) }
2402
+ // Log impact-level trust meta: how many impacted files use dynamic patterns
2403
+ // (→ the true blast may be larger than the count shown).
2404
+ const dynHits = r.files.filter((f) => (scanner.files.get(f.id)?.dynamicPatterns || []).length).length
2405
+ emitTrace('blast', id, { n: r.totalFiles, dyn: dynHits || undefined })
2406
+ // Send all impacted node ids to renderer for visual highlight
2407
+ mainWindow?.webContents.send('control:blast', { seed: id, ids: r.files.map((f) => f.id) })
2408
+ return writeJson(res, 200, r)
2409
+ // Send all impacted node ids to renderer for visual highlight
2410
+ mainWindow?.webContents.send('control:blast', { seed: id, ids: r.files.map((f) => f.id) })
2411
+ return writeJson(res, 200, r)
2412
+ }
2413
+ if (req.method === 'GET' && seg0 === 'history' && rest.length > 0) {
2414
+ return writeJson(res, 200, listHistory(currentRoot, traceId()))
2415
+ }
2416
+ if (req.method === 'POST' && seg0 === 'focus' && rest.length > 0) {
2417
+ const id = traceId()
2418
+ if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
2419
+ mainWindow?.webContents.send('control:focus', { id })
2420
+ return writeJson(res, 200, { ok: true, id })
2421
+ }
2422
+ if (req.method === 'POST' && seg0 === 'open' && rest.length > 0) {
2423
+ const id = traceId()
2424
+ if (!scanner.files.has(id)) return writeJson(res, 404, { error: 'not found' })
2425
+ mainWindow?.webContents.send('control:open', { id })
2426
+ return writeJson(res, 200, { ok: true, id })
2427
+ }
2428
+ if (req.method === 'POST' && seg0 === 'load' && rest.length === 0) {
2429
+ // Body: { path } — load a project folder. If same as currentRoot, no-op.
2430
+ // Used by `cs ensure` to auto-switch projects from CLI/MCP without
2431
+ // requiring the user to click "Open Folder" in the desktop UI.
2432
+ let bodyChunks = []
2433
+ req.on('data', (c) => bodyChunks.push(c))
2434
+ req.on('end', async () => {
2435
+ let target = null
2436
+ try {
2437
+ const bodyStr = Buffer.concat(bodyChunks).toString('utf8')
2438
+ if (bodyStr) target = JSON.parse(bodyStr)?.path || null
2439
+ } catch {}
2440
+ target = target || url.searchParams.get('path')
2441
+ if (!target) return writeJson(res, 400, { error: 'usage: { "path": "..." }' })
2442
+ let abs
2443
+ try {
2444
+ abs = path.resolve(target)
2445
+ // realpathSync normalizes symlinks so two different paths pointing
2446
+ // to the same directory hit the noop branch correctly.
2447
+ if (fs.existsSync(abs)) abs = fs.realpathSync(abs)
2448
+ } catch (e) { return writeJson(res, 400, { error: 'invalid path: ' + e.message }) }
2449
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
2450
+ return writeJson(res, 400, { error: 'not a directory: ' + abs })
2451
+ }
2452
+ // Reject the OS root (C:\ on Windows, / on POSIX) — scanning that
2453
+ // would walk the entire filesystem. Almost certainly a mistake.
2454
+ if (abs === path.parse(abs).root) {
2455
+ return writeJson(res, 400, { error: 'refusing to load OS root: ' + abs })
2456
+ }
2457
+ // Confirm we can actually read it before triggering a scan.
2458
+ try { fs.accessSync(abs, fs.constants.R_OK) }
2459
+ catch { return writeJson(res, 403, { error: 'not readable: ' + abs }) }
2460
+ if (currentRoot === abs && scanner) {
2461
+ return writeJson(res, 200, { ok: true, action: 'noop', root: currentRoot, fileCount: scanner.files.size })
2462
+ }
2463
+ try {
2464
+ await startScanner(abs)
2465
+ return writeJson(res, 200, { ok: true, action: 'loaded', root: currentRoot, fileCount: scanner?.files?.size || 0 })
2466
+ } catch (e) { return writeJson(res, 500, { error: e.message }) }
2467
+ })
2468
+ return
2469
+ }
2470
+ if (req.method === 'GET' && seg0 === 'trace' && rest.length === 0) {
2471
+ // Current session log. Filter by tool, since=, limit.
2472
+ const sinceRaw = url.searchParams.get('since')
2473
+ const toolFilter = url.searchParams.get('tool')
2474
+ const limit = parseInt(url.searchParams.get('limit') || '0', 10)
2475
+ let evs = traceLog
2476
+ if (sinceRaw) {
2477
+ const since = parseInt(sinceRaw, 10)
2478
+ evs = evs.filter((e) => e.ts > since)
2479
+ }
2480
+ if (toolFilter) evs = evs.filter((e) => e.tool === toolFilter)
2481
+ const totalAvailable = evs.length
2482
+ if (limit > 0) evs = evs.slice(-limit) // most recent N
2483
+ return writeJson(res, 200, withMeta({
2484
+ sessionId: traceSessionId,
2485
+ events: evs,
2486
+ }, { totalAvailable, returned: evs.length }))
2487
+ }
2488
+ if (req.method === 'GET' && seg0 === 'trace' && rest[0] === 'stats') {
2489
+ // Stats over current session
2490
+ const stats = computeTraceStats(traceLog)
2491
+ return writeJson(res, 200, withMeta({ sessionId: traceSessionId, ...stats }))
2492
+ }
2493
+ if (req.method === 'GET' && seg0 === 'trace' && rest[0] === 'sessions') {
2494
+ return writeJson(res, 200, withMeta({
2495
+ sessions: listTraceSessions(currentRoot),
2496
+ currentSessionId: traceSessionId,
2497
+ }))
2498
+ }
2499
+ if (req.method === 'GET' && seg0 === 'trace' && rest[0] === 'session' && rest[1]) {
2500
+ const id = parseInt(rest[1], 10)
2501
+ const data = readTraceSession(currentRoot, id)
2502
+ if (!data) return writeJson(res, 404, { error: 'session not found' })
2503
+ const stats = computeTraceStats(data.events)
2504
+ return writeJson(res, 200, withMeta({ ...data, stats }))
2505
+ }
2506
+ if (req.method === 'POST' && seg0 === 'trace' && rest[0] === 'clear') {
2507
+ // Soft clear: drop in-memory log + start a NEW session on disk so
2508
+ // old log file is preserved.
2509
+ traceLog = []
2510
+ startTraceSession()
2511
+ return writeJson(res, 200, { ok: true, newSessionId: traceSessionId })
2512
+ }
2513
+ if (req.method === 'POST' && seg0 === 'trace' && rest[0] === 'export') {
2514
+ // Write current session to a user-chosen path. Body should be
2515
+ // { path } or query ?path=. Returns the absolute output path.
2516
+ let bodyChunks = []
2517
+ req.on('data', (c) => bodyChunks.push(c))
2518
+ req.on('end', () => {
2519
+ let exportPath = url.searchParams.get('path')
2520
+ try {
2521
+ const body = Buffer.concat(bodyChunks).toString('utf8')
2522
+ if (body) {
2523
+ const parsed = JSON.parse(body)
2524
+ exportPath = exportPath || parsed?.path
2525
+ }
2526
+ } catch {}
2527
+ if (!exportPath) return writeJson(res, 400, { error: 'usage: pass ?path= or { "path": "..." }' })
2528
+ try {
2529
+ const stats = computeTraceStats(traceLog)
2530
+ const out = {
2531
+ sessionId: traceSessionId,
2532
+ root: currentRoot,
2533
+ startedAt: traceSessionStartedAt,
2534
+ exportedAt: Date.now(),
2535
+ stats,
2536
+ events: traceLog,
2537
+ }
2538
+ fs.writeFileSync(exportPath, JSON.stringify(out, null, 2), 'utf8')
2539
+ return writeJson(res, 200, { ok: true, path: exportPath, eventCount: traceLog.length })
2540
+ } catch (e) { return writeJson(res, 500, { error: e.message }) }
2541
+ })
2542
+ return
2543
+ }
2544
+ if (req.method === 'GET' && seg0 === 'legacy' && rest.length === 0) {
2545
+ // Async — returns once the legacy module is loaded
2546
+ buildLegacyCached().then((data) => {
2547
+ if (!data) return writeJson(res, 503, { error: 'no folder loaded' })
2548
+ // Optional ?type=orphan|path|filename|duplicate filter
2549
+ const type = url.searchParams.get('type')
2550
+ if (type) {
2551
+ const slice = { summary: data.summary }
2552
+ if (type === 'orphan') slice.orphans = data.orphans
2553
+ else if (type === 'path') slice.pathPatterns = data.pathPatterns
2554
+ else if (type === 'filename') slice.filenamePatterns = data.filenamePatterns
2555
+ else if (type === 'duplicate')slice.duplicates = data.duplicates
2556
+ else return writeJson(res, 400, { error: 'bad type; use orphan|path|filename|duplicate' })
2557
+ return writeJson(res, 200, withMeta(slice))
2558
+ }
2559
+ return writeJson(res, 200, withMeta(data))
2560
+ }).catch((e) => writeJson(res, 500, { error: e.message }))
2561
+ return
2562
+ }
2563
+ if (req.method === 'GET' && seg0 === 'packages' && rest.length === 0) {
2564
+ const data = buildPackagesCached()
2565
+ if (!data) return writeJson(res, 503, { error: 'no folder loaded' })
2566
+ return writeJson(res, 200, withMeta(data))
2567
+ }
2568
+ if (req.method === 'GET' && seg0 === 'package' && rest.length > 0) {
2569
+ const name = idFromRest()
2570
+ emitTrace('package', name)
2571
+ const d = buildPackageDetail(name)
2572
+ if (!d) return writeJson(res, 404, { error: 'package not found', name })
2573
+ return writeJson(res, 200, withMeta(d))
2574
+ }
2575
+ if (req.method === 'GET' && seg0 === 'package-graph') {
2576
+ const data = buildPackagesCached()
2577
+ if (!data) return writeJson(res, 503, { error: 'no folder loaded' })
2578
+ return writeJson(res, 200, withMeta({
2579
+ kind: data.kind,
2580
+ packages: data.packages.map((p) => ({ name: p.name, fileCount: p.fileCount })),
2581
+ edges: data.pkgEdges,
2582
+ }))
2583
+ }
2584
+ if (req.method === 'POST' && (seg0 === 'write' || seg0 === 'edit') && rest.length > 0) {
2585
+ // Body: /write/:id expects { content }
2586
+ // /edit/:id expects { find, replace, replaceAll? }
2587
+ // Both wrapped through writeFileToRoot so audit trail is uniform.
2588
+ const id = idFromRest()
2589
+ const full = path.join(currentRoot, id)
2590
+ if (!isInsideRoot(currentRoot, full)) return writeJson(res, 400, { error: 'outside root' })
2591
+ let bodyChunks = []
2592
+ req.on('data', (c) => bodyChunks.push(c))
2593
+ req.on('end', () => {
2594
+ let body
2595
+ try { body = JSON.parse(Buffer.concat(bodyChunks).toString('utf8')) }
2596
+ catch { return writeJson(res, 400, { error: 'invalid JSON body' }) }
2597
+ if (seg0 === 'write') {
2598
+ if (typeof body.content !== 'string') return writeJson(res, 400, { error: 'usage: { "content": "..." }' })
2599
+ const r = writeFileToRoot(id, body.content, { source: 'http-write' })
2600
+ if (!r.ok) return writeJson(res, 500, r)
2601
+ return writeJson(res, 200, withMeta({ ...r, id }))
2602
+ }
2603
+ // edit
2604
+ if (typeof body.find !== 'string' || typeof body.replace !== 'string') {
2605
+ return writeJson(res, 400, { error: 'usage: { "find": "...", "replace": "...", "replaceAll": false }' })
2606
+ }
2607
+ let content
2608
+ try { content = fs.readFileSync(full, 'utf8') }
2609
+ catch (e) { return writeJson(res, 500, { error: 'read failed: ' + e.message }) }
2610
+ const findStr = body.find
2611
+ if (!findStr) return writeJson(res, 400, { error: 'find string cannot be empty' })
2612
+ // Count occurrences
2613
+ let count = 0, idx = 0
2614
+ while ((idx = content.indexOf(findStr, idx)) !== -1) { count++; idx += findStr.length }
2615
+ if (count === 0) return writeJson(res, 404, { error: 'find string not found', find: findStr })
2616
+ const replaceAll = body.replaceAll === true
2617
+ if (!replaceAll && count > 1) {
2618
+ return writeJson(res, 409, {
2619
+ error: `find string is not unique (${count} occurrences). Pass replaceAll:true or use a more specific find string.`,
2620
+ occurrences: count,
2621
+ })
2622
+ }
2623
+ const next = replaceAll
2624
+ ? content.split(findStr).join(body.replace)
2625
+ : content.replace(findStr, body.replace)
2626
+ const r = writeFileToRoot(id, next, { source: 'http-edit' })
2627
+ if (!r.ok) return writeJson(res, 500, r)
2628
+ return writeJson(res, 200, withMeta({ ...r, id, replacements: replaceAll ? count : 1 }))
2629
+ })
2630
+ return
2631
+ }
2632
+ if (req.method === 'POST' && seg0 === 'restore' && rest.length > 0) {
2633
+ const id = idFromRest()
2634
+ emitTrace('write', id)
2635
+ const ts = parseInt(url.searchParams.get('ts') || '0', 10)
2636
+ if (!ts) return writeJson(res, 400, { error: 'missing ts' })
2637
+ const content = readHistorySnap(currentRoot, id, ts)
2638
+ if (content === null) return writeJson(res, 404, { error: 'snapshot not found' })
2639
+ const full = path.join(currentRoot, id)
2640
+ if (!isInsideRoot(currentRoot, full)) return writeJson(res, 400, { error: 'outside root' })
2641
+ try {
2642
+ fs.writeFileSync(full, content, 'utf8')
2643
+ snapshotHistory(currentRoot, id, content)
2644
+ return writeJson(res, 200, { ok: true, id, ts })
2645
+ } catch (e) { return writeJson(res, 500, { error: e.message }) }
2646
+ }
2647
+ return writeJson(res, 404, { error: 'unknown endpoint', path: url.pathname })
2648
+ } catch (e) {
2649
+ return writeJson(res, 500, { error: e.message })
2650
+ }
2651
+ }
2652
+
2653
+ // Try `startPort` first, then increment up to `maxTries-1` more.
2654
+ // On success: write port to lock file so CLI / MCP can find us.
2655
+ // On exhaustion: log + give up (control API disabled).
2656
+ const CONTROL_PORT_MAX_TRIES = 10
2657
+ function getLockFilePath() {
2658
+ // ~/.codesynapt/port — CLI/MCP looks here when no env var set
2659
+ const homeDir = app.getPath('home')
2660
+ return path.join(homeDir, '.codesynapt', 'port')
2661
+ }
2662
+ function writeLockFile(port) {
2663
+ try {
2664
+ const file = getLockFilePath()
2665
+ fs.mkdirSync(path.dirname(file), { recursive: true })
2666
+ fs.writeFileSync(file, String(port), 'utf8')
2667
+ } catch (e) {
2668
+ console.warn('[cs] could not write port lock file:', e.message)
2669
+ }
2670
+ }
2671
+ function clearLockFile() {
2672
+ try { fs.unlinkSync(getLockFilePath()) } catch {}
2673
+ }
2674
+ function startControlServer(startPort = controlPort, attempt = 0) {
2675
+ if (controlServer) return
2676
+ const port = startPort + attempt
2677
+ const server = http.createServer(handleControlRequest)
2678
+ server.once('error', (err) => {
2679
+ if (err.code === 'EADDRINUSE' && attempt < CONTROL_PORT_MAX_TRIES - 1) {
2680
+ // Try next port. Recurse without setting controlServer yet.
2681
+ try { server.close() } catch {}
2682
+ startControlServer(startPort, attempt + 1)
2683
+ } else if (err.code === 'EADDRINUSE') {
2684
+ console.warn(`[cs] all control ports ${startPort}..${startPort + CONTROL_PORT_MAX_TRIES - 1} in use — control API disabled. Set CS_PORT to override.`)
2685
+ controlServer = null
2686
+ } else {
2687
+ console.error('[cs] control server error:', err)
2688
+ controlServer = null
2689
+ }
2690
+ })
2691
+ server.listen(port, '127.0.0.1', () => {
2692
+ controlServer = server
2693
+ controlPort = port
2694
+ writeLockFile(port)
2695
+ if (port !== startPort) {
2696
+ log.info('control API listening (fallback)', { port, requestedPort: startPort, host: '127.0.0.1' })
2697
+ console.log(`[cs] control API listening on http://127.0.0.1:${port} (fallback — ${startPort} was in use)`)
2698
+ } else {
2699
+ log.info('control API listening', { port, host: '127.0.0.1' })
2700
+ console.log(`[cs] control API listening on http://127.0.0.1:${port}`)
2701
+ }
2702
+ })
2703
+ }
2704
+ function stopControlServer() {
2705
+ if (controlServer) {
2706
+ try { controlServer.close() } catch {}
2707
+ controlServer = null
2708
+ }
2709
+ clearLockFile()
2710
+ }
2711
+ ipcMain.handle('control-port', () => controlPort)
2712
+
2713
+ // ─── Log retention (audit + per-project traces) ────────────────
2714
+ // Default 30 days; configurable via env. Prevents unbounded growth of
2715
+ // ~/.codesynapt/audit/YYYY-MM-DD.jsonl and project-local .codesynapt/traces/.
2716
+ function pruneOldLogs() {
2717
+ const days = parseInt(process.env.CS_AUDIT_RETENTION_DAYS || '30', 10)
2718
+ if (!days || days <= 0) return // 0 = keep forever
2719
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000
2720
+ const auditDir = path.join(app.getPath('home'), '.codesynapt', 'audit')
2721
+ const dirs = [auditDir]
2722
+ if (currentRoot) dirs.push(traceDirFor(currentRoot))
2723
+ for (const dir of dirs) {
2724
+ try {
2725
+ if (!fs.existsSync(dir)) continue
2726
+ for (const name of fs.readdirSync(dir)) {
2727
+ if (!name.endsWith('.jsonl')) continue
2728
+ const f = path.join(dir, name)
2729
+ try {
2730
+ const stat = fs.statSync(f)
2731
+ if (stat.mtimeMs < cutoffMs) {
2732
+ fs.unlinkSync(f)
2733
+ log.info('log pruned', { file: f, retentionDays: days })
2734
+ }
2735
+ } catch {}
2736
+ }
2737
+ } catch {}
2738
+ }
2739
+ }
2740
+
2741
+ // CS_HEADLESS=1 boots the control API + scanner without opening a
2742
+ // BrowserWindow. Useful for: bench harnesses, CI runs, or a second
2743
+ // instance on a non-default CS_PORT measuring alongside a live UI
2744
+ // without spawning a second visible window.
2745
+ const HEADLESS = process.env.CS_HEADLESS === '1'
2746
+
2747
+ // ─── Single-instance lock ───────────────────────────────────────
2748
+ // Critical for clean version upgrades: when the NSIS installer launches
2749
+ // the new version (runAfterFinish), if an old build is somehow still
2750
+ // running, route the second-instance event to focus/restore instead of
2751
+ // spawning a duplicate process. Also prevents two desktop windows
2752
+ // fighting over port 7707.
2753
+ // Headless mode skips the lock — bench harnesses and CI runs need to
2754
+ // boot a second instance on a non-default CS_PORT alongside a live
2755
+ // desktop without one killing the other.
2756
+ const gotSingleInstanceLock = HEADLESS ? true : app.requestSingleInstanceLock()
2757
+ if (!gotSingleInstanceLock) {
2758
+ app.quit()
2759
+ } else {
2760
+ app.on('second-instance', (_event, argv) => {
2761
+ if (mainWindow) {
2762
+ if (mainWindow.isMinimized()) mainWindow.restore()
2763
+ if (!mainWindow.isVisible()) mainWindow.show()
2764
+ mainWindow.focus()
2765
+ }
2766
+ // If the second instance passed CS_INITIAL_ROOT via env or a path arg,
2767
+ // load that folder into the running window.
2768
+ const envRoot = process.env.CS_INITIAL_ROOT
2769
+ const argRoot = argv && argv.find && argv.find((a) => a && !a.startsWith('-') && fs.existsSync(a) && fs.statSync(a).isDirectory())
2770
+ const target = envRoot && fs.existsSync(envRoot) ? envRoot : argRoot
2771
+ if (target && path.resolve(target) !== currentRoot) startScanner(path.resolve(target))
2772
+ })
2773
+ }
2774
+
2775
+ // ─── App lifecycle ──────────────────────────────────────────────
2776
+ app.whenReady().then(() => {
2777
+ if (!HEADLESS) {
2778
+ rebuildMenu()
2779
+ createWindow()
2780
+ } else {
2781
+ // Electron has no GUI-less mode — without at least one
2782
+ // BrowserWindow the app exits as soon as whenReady() resolves.
2783
+ // A 1×1 hidden window keeps the event loop alive and never
2784
+ // surfaces on screen, which is what we want for bench harnesses
2785
+ // and CI runs that only need the control API.
2786
+ new BrowserWindow({ show: false, width: 1, height: 1 })
2787
+ }
2788
+ startControlServer()
2789
+ pruneOldLogs() // one-shot on boot; daily users get fresh pruning
2790
+
2791
+ app.on('activate', () => {
2792
+ if (!HEADLESS && BrowserWindow.getAllWindows().length === 0) createWindow()
2793
+ })
2794
+
2795
+ // Auto-updater (GitHub Releases). Disabled by env var for users who
2796
+ // want zero outbound network calls. Silently no-ops if no published
2797
+ // releases yet (404 from update feed → updater logs and does nothing).
2798
+ if (process.env.CS_DISABLE_UPDATER !== '1') {
2799
+ try {
2800
+ const { autoUpdater } = require('electron-updater')
2801
+ autoUpdater.autoDownload = false // ask user before downloading
2802
+ autoUpdater.on('error', (e) => log.error('updater error', { message: e.message }))
2803
+ autoUpdater.on('update-available', (info) => {
2804
+ log.info('update available', { version: info.version })
2805
+ // Notify renderer; renderer shows a toast with "download / dismiss".
2806
+ mainWindow?.webContents.send('updater:available', { version: info.version, releaseNotes: info.releaseNotes })
2807
+ })
2808
+ autoUpdater.on('update-downloaded', (info) => {
2809
+ mainWindow?.webContents.send('updater:downloaded', { version: info.version })
2810
+ })
2811
+ // Check ~10s after boot so the scanner gets the network priority.
2812
+ setTimeout(() => { autoUpdater.checkForUpdates().catch(() => {}) }, 10_000)
2813
+ } catch (e) {
2814
+ // electron-updater is optional at runtime; CLI/MCP-only installs skip it
2815
+ log.warn('updater not loaded', { error: e.message })
2816
+ }
2817
+ }
2818
+ })
2819
+
2820
+ app.on('window-all-closed', () => {
2821
+ // Headless mode never opens a window, so this would fire as soon
2822
+ // as the event loop ticks and quit before the control API became
2823
+ // useful. Stay alive — caller terminates with SIGTERM.
2824
+ if (HEADLESS) return
2825
+ stopScanner()
2826
+ stopControlServer()
2827
+ if (process.platform !== 'darwin') app.quit()
2828
+ })
2829
+
2830
+ app.on('before-quit', () => { stopScanner(); stopControlServer() })
2831
+
2832
+ // Hardening
2833
+ app.on('web-contents-created', (_e, contents) => {
2834
+ contents.setWindowOpenHandler(({ url }) => {
2835
+ if (url.startsWith('http')) shell.openExternal(url)
2836
+ return { action: 'deny' }
2837
+ })
2838
+ // Defense-in-depth: block all in-window navigation away from our file://.
2839
+ // CSP already blocks remote loads, but this is one more layer.
2840
+ contents.on('will-navigate', (event, url) => {
2841
+ if (!url.startsWith('file://')) {
2842
+ event.preventDefault()
2843
+ if (url.startsWith('http')) shell.openExternal(url)
2844
+ }
2845
+ })
2846
+ // Deny every permission request — we don't use mic/camera/geolocation/etc.
2847
+ contents.session.setPermissionRequestHandler((_wc, _perm, callback) => callback(false))
2848
+ contents.session.setPermissionCheckHandler(() => false)
2849
+ })