weavatrix 0.2.3 → 0.2.4

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.
@@ -1,7 +1,7 @@
1
1
  // Health tools: clone detection, the internal audit, community/module overviews, coverage mapping
2
2
  // and the HTTP endpoint inventory. Hot-reloadable (re-imported by catalog.mjs on change).
3
3
  import {spawnSync} from 'node:child_process'
4
- import {degreeOf, rawGraph} from './graph-context.mjs'
4
+ import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
5
5
  import {computeDuplicates} from '../analysis/duplicates.js'
6
6
  import {runInternalAudit} from '../analysis/internal-audit.js'
7
7
  import {classifyChangeImpact} from '../analysis/change-classification.js'
@@ -17,8 +17,9 @@ import {buildInternalGraph} from '../graph/internal-builder.js'
17
17
  import {resolveGitCommit, withGitRefCheckout} from '../analysis/git-ref-graph.js'
18
18
  import {childProcessEnv} from '../child-env.js'
19
19
  import {toolResult} from './tool-result.mjs'
20
- import {createPathClassifier} from '../path-classification.js'
20
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
21
21
  import {createRepoBoundary} from '../repo-path.js'
22
+ import {filterGraphForMode} from '../graph/graph-filter.js'
22
23
 
23
24
  const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
24
25
  const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
@@ -117,7 +118,7 @@ export function tFindDuplicates(g, args, ctx) {
117
118
  // candidates. It never returns an automatic-delete verdict.
118
119
  export function tFindDeadCode(g, args, ctx) {
119
120
  if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
120
- const graph = rawGraph(ctx)
121
+ const graph = effectiveRawGraph(ctx)
121
122
  const boundary = createRepoBoundary(ctx.repoRoot)
122
123
  const pkg = readRepoJson(boundary, 'package.json') || {}
123
124
  const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
@@ -323,7 +324,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
323
324
  }
324
325
 
325
326
  const baseline = await withGitRefCheckout(ctx.repoRoot, resolved.commit, async (checkout) => {
326
- const graph = await buildInternalGraph(checkout)
327
+ let graph = await buildInternalGraph(checkout)
328
+ const currentMode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode)
329
+ ? currentGraph.graphBuildMode : 'full'
330
+ if (currentMode !== 'full') graph = filterGraphForMode(graph, currentMode, {repoRoot: checkout})
331
+ graph.graphBuildMode = currentMode
332
+ graph.graphBuildScope = ''
327
333
  return runInternalAudit(checkout, {graph, skipMalwareScan: true})
328
334
  })
329
335
  if (!baseline.ok || !baseline.value?.ok) {
@@ -385,7 +391,7 @@ export async function tRunAudit(g, args, ctx) {
385
391
  if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
386
392
  if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
387
393
  const audit = await runInternalAudit(ctx.repoRoot, {
388
- graph: rawGraph(ctx),
394
+ graph: effectiveRawGraph(ctx),
389
395
  skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
390
396
  })
391
397
  if (!audit.ok) return `Audit failed: ${audit.error}`
@@ -440,7 +446,36 @@ export function tListCommunities(g, args, ctx) {
440
446
  // Folder-level architecture map: modules (top-two path segments) with file/symbol counts and the
441
447
  // strongest module→module dependencies. Pure graph aggregation — no filesystem reads.
442
448
  export function tModuleMap(g, args, ctx) {
443
- const agg = aggregateGraph(rawGraph(ctx), null)
449
+ const graph = rawGraph(ctx)
450
+ const testsOnly = graph.graphBuildMode === 'tests-only'
451
+ const includeNonProduct = args.include_non_product === true || testsOnly
452
+ const classifier = createPathClassifier(ctx?.repoRoot || null)
453
+ const nonProductFiles = new Set()
454
+ const classifiedFiles = new Set()
455
+ if (!includeNonProduct) {
456
+ for (const node of graph.nodes || []) {
457
+ if (!node?.source_file) continue
458
+ const sourceFile = String(node.source_file)
459
+ if (classifiedFiles.has(sourceFile)) continue
460
+ classifiedFiles.add(sourceFile)
461
+ const explanation = classifier.explain(sourceFile)
462
+ if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
463
+ nonProductFiles.add(sourceFile)
464
+ }
465
+ }
466
+ }
467
+ const visibleGraph = includeNonProduct || nonProductFiles.size === 0 ? graph : (() => {
468
+ const keep = new Set((graph.nodes || [])
469
+ .filter((node) => !node?.source_file || !nonProductFiles.has(String(node.source_file)))
470
+ .map((node) => String(node.id)))
471
+ const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
472
+ return {
473
+ ...graph,
474
+ nodes: (graph.nodes || []).filter((node) => keep.has(String(node.id))),
475
+ links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
476
+ }
477
+ })()
478
+ const agg = aggregateGraph(visibleGraph, null)
444
479
  const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
445
480
  const mods = agg.modules.slice(0, topN)
446
481
  const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
@@ -458,6 +493,11 @@ export function tModuleMap(g, args, ctx) {
458
493
  collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
459
494
  const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
460
495
  return [
496
+ testsOnly
497
+ ? 'Scope: tests-only graph; classified test/e2e/fixture surfaces are retained automatically.'
498
+ : includeNonProduct
499
+ ? 'Scope: all indexed files, including classified non-product surfaces.'
500
+ : `Scope: production-only (default); excluded ${nonProductFiles.size} classified test/fixture/benchmark/generated/docs file(s). Pass include_non_product:true for the complete graph.`,
461
501
  `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.compileTimeModuleEdges || 0} compile-time dependencies (${agg.totals.typeOnlyModuleEdges || 0} type-only, ${agg.totals.compileOnlyModuleEdges || 0} compile-only). Top ${mods.length}:`,
462
502
  ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
463
503
  ``,
@@ -31,9 +31,11 @@ function reverseReach(g, seeds, maxDepth) {
31
31
  }
32
32
  const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
33
33
  const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
34
+ const provenanceKey = compileOnly ? 'compileProvenance' : 'runtimeProvenance'
34
35
  if (entry[depthKey] != null && entry[depthKey] <= depth) continue
35
36
  entry[depthKey] = depth
36
37
  entry[relationKey] = e.relation || 'rel'
38
+ entry[provenanceKey] = e.provenance || 'UNKNOWN'
37
39
  states.set(id, entry)
38
40
  frontier.push({id, depth, compileOnly})
39
41
  }
@@ -43,6 +45,7 @@ function reverseReach(g, seeds, maxDepth) {
43
45
  depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
44
46
  compileOnly: entry.runtimeDepth == null,
45
47
  relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
48
+ provenance: entry.runtimeDepth != null ? entry.runtimeProvenance : entry.compileProvenance,
46
49
  }]))
47
50
  }
48
51
 
@@ -86,17 +89,21 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
86
89
  note,
87
90
  `Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
88
91
  containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
89
- ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
92
+ ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} [${r.entry.provenance || 'UNKNOWN'}] ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
90
93
  ].filter(Boolean).join('\n')
91
94
  }
92
95
 
93
96
  // Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
94
97
  export async function tGraphDiff(g, args, ctx) {
98
+ const current = rawGraph(ctx)
99
+ const currentMode = ['full', 'no-tests', 'tests-only'].includes(current?.graphBuildMode)
100
+ ? current.graphBuildMode
101
+ : 'full'
95
102
  let prev
96
103
  let baselineLabel = 'previous rebuild state'
97
104
  if (args.base_ref) {
98
105
  if (!ctx.repoRoot) return 'A Git-ref graph diff needs the active repository root.'
99
- const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref)
106
+ const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref, {mode: currentMode})
100
107
  if (!built.ok) return `Could not build the baseline graph: ${built.error}`
101
108
  prev = built.graph
102
109
  baselineLabel = `${built.ref} (${built.commit.slice(0, 12)})`
@@ -106,7 +113,17 @@ export async function tGraphDiff(g, args, ctx) {
106
113
  return `No previous graph state at ${prevPath} — pass base_ref (for example HEAD~1 or main), or run rebuild_graph to save one automatically.`
107
114
  }
108
115
  }
109
- const current = rawGraph(ctx)
116
+ if (!args.base_ref) {
117
+ const previousMode = ['full', 'no-tests', 'tests-only'].includes(prev?.graphBuildMode)
118
+ ? prev.graphBuildMode
119
+ : 'full'
120
+ if (previousMode !== currentMode) {
121
+ return [
122
+ `Graph diff unavailable: previous graph mode is ${previousMode}, current graph mode is ${currentMode}.`,
123
+ 'Those node/edge universes are not comparable. Run rebuild_graph once more in the same mode, or pass base_ref to build a mode-matched immutable baseline.',
124
+ ].join('\n')
125
+ }
126
+ }
110
127
  const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
111
128
  const scope = (graph) => filter ? {
112
129
  nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
@@ -118,6 +135,7 @@ export async function tGraphDiff(g, args, ctx) {
118
135
  } : graph
119
136
  return [
120
137
  `Graph diff (${baselineLabel} → current)${filter ? `, scoped to ${filter}` : ''}:`,
138
+ `Build mode: ${currentMode}`,
121
139
  formatGraphDiff(diffGraphs(scope(prev), scope(current)))
122
140
  ].join('\n')
123
141
  }
@@ -7,9 +7,10 @@
7
7
  // Spawned by Claude Code / Codex as a plain Node child (node mcp-server.mjs <graph.json> <repoRoot>).
8
8
  // Speaks newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio transport).
9
9
  //
10
- // .mjs on purpose: guarantees ESM parsing regardless of the nearest package.json. The server itself
11
- // resolves nothing from node_modules at runtime (ripgrep is probed, with a pure-Node fallback); only
12
- // the graph BUILDER pulls in web-tree-sitter + its WASM grammars when a build is requested.
10
+ // .mjs on purpose: guarantees ESM parsing regardless of the nearest package.json. Runtime analyzers
11
+ // resolve only dependencies bundled with Weavatrix: web-tree-sitter grammars for graph builds and the
12
+ // pinned TypeScript language server for bounded JS/TS semantic evidence. Repository packages/scripts
13
+ // are never executed to provide either analyzer.
13
14
  //
14
15
  // STDOUT is the protocol channel — nothing but JSON-RPC frames may be written there. All diagnostics
15
16
  // go to stderr. Two argv forms:
@@ -24,8 +25,10 @@ import {graphOutDirForRepo} from './graph/layout.js'
24
25
  import {createRequire} from 'node:module'
25
26
  import {createStalenessNoticeGate} from './mcp/staleness-notice.mjs'
26
27
  import {normalizeToolResult} from './mcp/tool-result.mjs'
27
- import {buildGraphForRepo} from './build-graph.js'
28
+ import {buildGraphForRepo, defaultPrecisionMode} from './build-graph.js'
28
29
  import {persistedFreshnessMatches, repositoryFreshnessProbe} from './graph/freshness-probe.js'
30
+ import {activeLspClientCount, beginLspClientShutdown, shutdownActiveLspClients} from './precision/lsp-client.js'
31
+ import {PRECISION_OVERLAY_V, precisionSemanticInputsMatch, readPrecisionOverlay} from './precision/lsp-overlay.js'
29
32
 
30
33
  // version comes from package.json so serverInfo can never drift from the published package again
31
34
  const PKG_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version } catch { return '0.0.0' } })()
@@ -33,6 +36,16 @@ const SERVER_INFO = {name: 'weavatrix', version: PKG_VERSION}
33
36
  const DEFAULT_PROTOCOL = '2024-11-05'
34
37
  const log = (...a) => process.stderr.write(`[weavatrix] ${a.join(' ')}\n`)
35
38
 
39
+ async function settleWithin(promise, timeoutMs) {
40
+ let timer
41
+ const settled = await Promise.race([
42
+ Promise.resolve(promise).then(() => true, () => true),
43
+ new Promise((resolveSettled) => { timer = setTimeout(() => resolveSettled(false), timeoutMs) }),
44
+ ])
45
+ if (timer) clearTimeout(timer)
46
+ return settled
47
+ }
48
+
36
49
  // argv[2] is a repo DIRECTORY in the npx form — derive the graph location from the standard layout;
37
50
  // otherwise it is the graph.json path and the repo root follows it.
38
51
  let GRAPH_PATH = process.argv[2]
@@ -77,7 +90,7 @@ async function main() {
77
90
  // ctx owns the CURRENT target: rebuild_graph reloads it, open_repo retargets graphPath/repoRoot
78
91
  // at runtime. loadInto always reads ctx.graphPath so both paths share one loader.
79
92
  const ctx = {graphPath: GRAPH_PATH, repoRoot: REPO_ROOT, reload: null}
80
- const loadInto = () => { graph = loadGraph(ctx.graphPath); graphError = null; return graph }
93
+ const loadInto = () => { graph = loadGraph(ctx.graphPath, {repoRoot: ctx.repoRoot}); graphError = null; return graph }
81
94
  ctx.reload = () => { api.resetStalenessCache(); try { return loadInto() } catch (e) { graphError = e.message; return null } }
82
95
  try {
83
96
  if (!ctx.graphPath) throw new Error('no graph.json path given (argv[2])')
@@ -96,6 +109,8 @@ async function main() {
96
109
  // ordinary tools use a per-call graph/context snapshot, so an explicitly retargetable registration
97
110
  // cannot mix one repo's in-memory graph with another repo's source root under concurrent MCP calls.
98
111
  let targetMutation = Promise.resolve()
112
+ let shuttingDown = false
113
+ let shutdownPromise = null
99
114
  const refreshProbeCache = new Map()
100
115
  const configuredDebounce = process.env.WEAVATRIX_AUTO_REFRESH_DEBOUNCE_MS == null
101
116
  ? 2_000
@@ -103,13 +118,31 @@ async function main() {
103
118
  const refreshDebounceMs = Math.max(0, Math.min(5_000, Number.isFinite(configuredDebounce) ? configuredDebounce : 2_000))
104
119
  const autoRefresh = async (callCtx, currentGraph) => {
105
120
  if (!callCtx?.repoRoot || !callCtx?.graphPath) return {graph: null, refresh: null}
106
- const probeKey = `${callCtx.graphPath}\0${currentGraph?.graphBuildMode || 'full'}`
121
+ const activePrecision = currentGraph?.graphPrecisionMode || defaultPrecisionMode()
122
+ const probeKey = `${callCtx.graphPath}\0${currentGraph?.graphBuildMode || 'full'}\0${activePrecision}`
123
+ // Semantic inputs include ignored configs and configured project files that Git status does
124
+ // not see. Check their bounded fingerprint before the ordinary source freshness debounce;
125
+ // exact evidence must have no stale window, even on back-to-back tool calls.
126
+ let semanticInputsChanged = false
127
+ if (activePrecision === 'lsp' && currentGraph) {
128
+ try {
129
+ const overlay = readPrecisionOverlay(callCtx.graphPath, currentGraph)
130
+ semanticInputsChanged = typeof overlay?.semanticInputFingerprint === 'string'
131
+ && !precisionSemanticInputsMatch(overlay, callCtx.repoRoot, currentGraph)
132
+ } catch {
133
+ semanticInputsChanged = true
134
+ }
135
+ }
107
136
  const cachedProbe = refreshProbeCache.get(probeKey)
108
- if (currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
137
+ if (!semanticInputsChanged && currentGraph && cachedProbe && Date.now() - cachedProbe.checkedAt < refreshDebounceMs) {
109
138
  return {graph: currentGraph, refresh: {kind: 'none', revision: currentGraph.graphRevision || null, changedFiles: 0}}
110
139
  }
111
140
  const beforeProbe = repositoryFreshnessProbe(callCtx.repoRoot)
112
- if (beforeProbe && currentGraph && (
141
+ const precisionMissing = activePrecision === 'lsp' && (
142
+ Number(currentGraph?.precisionOverlayV) !== PRECISION_OVERLAY_V
143
+ || semanticInputsChanged
144
+ )
145
+ if (!precisionMissing && beforeProbe && currentGraph && (
113
146
  cachedProbe?.probe === beforeProbe
114
147
  || persistedFreshnessMatches(currentGraph, beforeProbe, currentGraph.graphBuildMode || 'full')
115
148
  )) {
@@ -118,12 +151,13 @@ async function main() {
118
151
  }
119
152
  const result = await buildGraphForRepo(callCtx.repoRoot, {
120
153
  mode: currentGraph?.graphBuildMode || 'full',
154
+ precision: activePrecision,
121
155
  scope: '',
122
156
  outDir: dirname(callCtx.graphPath),
123
157
  })
124
158
  if (!result.ok) throw new Error(result.error || 'automatic graph refresh failed')
125
159
  api.resetStalenessCache()
126
- const fresh = loadGraph(callCtx.graphPath)
160
+ const fresh = loadGraph(callCtx.graphPath, {repoRoot: callCtx.repoRoot})
127
161
  const afterProbe = repositoryFreshnessProbe(callCtx.repoRoot)
128
162
  if (afterProbe && afterProbe === beforeProbe) refreshProbeCache.set(probeKey, {probe: afterProbe, checkedAt: Date.now()})
129
163
  else refreshProbeCache.delete(probeKey)
@@ -138,10 +172,53 @@ async function main() {
138
172
  },
139
173
  }
140
174
  }
141
- const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n')
175
+ const send = (msg) => {
176
+ // A request that was already running when the client disconnected may complete during the
177
+ // bounded drain. Do not write a late reply into a closed protocol pipe.
178
+ if (shuttingDown || process.stdout.destroyed || !process.stdout.writable) return false
179
+ try {
180
+ return process.stdout.write(JSON.stringify(msg) + '\n')
181
+ } catch (error) {
182
+ if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') return false
183
+ throw error
184
+ }
185
+ }
142
186
  const reply = (id, result) => send({jsonrpc: '2.0', id, result})
143
187
  const fail = (id, code, message) => send({jsonrpc: '2.0', id, error: {code, message}})
144
188
 
189
+ const requestShutdown = (reason, exitCode = 0) => {
190
+ if (shutdownPromise) return shutdownPromise
191
+ shuttingDown = true
192
+ // This is synchronous and deliberately precedes every await: a graph build that reaches its
193
+ // precision phase during the drain must not create a fresh TLS/tsserver process tree.
194
+ beginLspClientShutdown()
195
+ process.stdin.pause()
196
+ const activeAtStart = activeLspClientCount()
197
+ log(`shutdown requested (${reason}); draining graph work and ${activeAtStart} semantic provider(s)`)
198
+ shutdownPromise = (async () => {
199
+ // Give an ordinary auto-refresh a chance to commit and close its provider itself. A wedged
200
+ // parse/query is bounded; active semantic children are then closed or tree-killed, after
201
+ // which the mutation gets a final window to observe that cancellation and release locks.
202
+ const initiallyDrained = await settleWithin(targetMutation, 2_500)
203
+ const semantic = await shutdownActiveLspClients({timeoutMs: 3_000})
204
+ const fullyDrained = initiallyDrained || await settleWithin(targetMutation, 1_500)
205
+ log(`shutdown cleanup: graph=${fullyDrained ? 'drained' : 'bounded-timeout'}, semantic=${semantic.requested} requested/${semantic.remaining} remaining${semantic.timedOut ? ' (forced)' : ''}`)
206
+ })().catch((error) => {
207
+ log(`shutdown cleanup failed: ${error.stack || error.message}`)
208
+ }).finally(() => {
209
+ process.exit(exitCode)
210
+ })
211
+ return shutdownPromise
212
+ }
213
+ process.stdout.on('error', (error) => {
214
+ if (error?.code === 'EPIPE' || error?.code === 'ERR_STREAM_DESTROYED') {
215
+ void requestShutdown('stdout disconnected')
216
+ return
217
+ }
218
+ log(`stdout error: ${error.stack || error.message}`)
219
+ void requestShutdown('stdout error', 1)
220
+ })
221
+
145
222
  let loadedVersion = hotVersion(catalog.HOT_FILES)
146
223
  let lastFailedVersion = 0
147
224
  const maybeHotReload = async () => {
@@ -164,6 +241,10 @@ async function main() {
164
241
  const handle = async (msg) => {
165
242
  const {id, method, params} = msg
166
243
  const isNotification = id === undefined || id === null
244
+ if (shuttingDown) {
245
+ if (!isNotification) fail(id, -32000, 'MCP server is shutting down')
246
+ return
247
+ }
167
248
  if (method === 'initialize') {
168
249
  if (params?.protocolVersion) protocolVersion = String(params.protocolVersion)
169
250
  return reply(id, {protocolVersion, capabilities: {tools: {listChanged: true}}, serverInfo: SERVER_INFO})
@@ -172,10 +253,14 @@ async function main() {
172
253
  if (method === 'ping') return reply(id, {})
173
254
  if (method === 'tools/list') {
174
255
  await maybeHotReload()
256
+ if (shuttingDown) return
175
257
  return reply(id, {tools: api.tools.map(({name, description, inputSchema, outputSchema}) => ({name, description, inputSchema, outputSchema}))})
176
258
  }
177
259
  if (method === 'tools/call') {
178
260
  await maybeHotReload()
261
+ // EOF can arrive while the hot-reload import is pending. Do not enqueue a graph mutation
262
+ // after requestShutdown captured the mutation chain it is responsible for draining.
263
+ if (shuttingDown) return
179
264
  const tool = api.byName.get(params?.name)
180
265
  if (!tool) return reply(id, {content: [{type: 'text', text: `Unknown tool: ${params?.name}`}], isError: true})
181
266
  const refreshesGraph = tool.cap === 'graph' || tool.cap === 'health' || tool.refreshGraph === true
@@ -236,6 +321,7 @@ async function main() {
236
321
  let buf = ''
237
322
  process.stdin.setEncoding('utf8')
238
323
  process.stdin.on('data', (chunk) => {
324
+ if (shuttingDown) return
239
325
  buf += chunk
240
326
  let nl
241
327
  while ((nl = buf.indexOf('\n')) >= 0) {
@@ -256,7 +342,9 @@ async function main() {
256
342
  })
257
343
  }
258
344
  })
259
- process.stdin.on('end', () => process.exit(0))
345
+ process.stdin.on('end', () => { void requestShutdown('stdin EOF') })
346
+ process.on('SIGTERM', () => { void requestShutdown('SIGTERM') })
347
+ process.on('SIGINT', () => { void requestShutdown('SIGINT') })
260
348
  log('ready')
261
349
  }
262
350
 
@@ -80,8 +80,8 @@ const DEFAULT_RULES = [
80
80
  },
81
81
  {
82
82
  category: "temp",
83
- pattern: "__temp working/generated root",
84
- regex: /(^|\/)__temp(\/|$)/i,
83
+ pattern: "__temp or .tmp-* working/generated root",
84
+ regex: /(^|\/)(?:__temp|\.tmp(?:[-_][^/]*)?)(\/|$)/i,
85
85
  },
86
86
  ];
87
87