@colbymchenry/codegraph 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -606,6 +606,18 @@ add a negation — `!vendor/`. The defaults apply uniformly, so committing a
606
606
  dependency or build directory doesn't force it into the graph; the `.gitignore`
607
607
  negation is the explicit opt-in.
608
608
 
609
+ `.gitignore` can't drop a directory you've **committed**, though. For a vendored
610
+ theme or SDK that's checked into the repo (e.g. a Metronic theme under
611
+ `static/`), list it under `exclude` in `codegraph.json` — gitignore-style
612
+ patterns, matched against repo-root-relative paths, honored on index, sync, and
613
+ watch:
614
+
615
+ ```json
616
+ {
617
+ "exclude": ["static/", "**/vendor/**"]
618
+ }
619
+ ```
620
+
609
621
  ### Custom file extensions
610
622
 
611
623
  If your project uses a non-standard extension for a [supported
@@ -0,0 +1,12 @@
1
+ export interface CommandSupervision {
2
+ /** Tear down both watchdogs. Idempotent; call when the command finishes. */
3
+ stop(): void;
4
+ }
5
+ /**
6
+ * Install the liveness + PPID watchdogs for the duration of a CLI command.
7
+ * `label` is used in the shutdown notice (e.g. `"index"`). Returns a handle
8
+ * whose `stop()` must be called when the command completes so neither watchdog
9
+ * outlives it.
10
+ */
11
+ export declare function installCommandSupervision(label: string): CommandSupervision;
12
+ //# sourceMappingURL=command-supervision.d.ts.map
@@ -85,6 +85,38 @@ export declare function findIndexedSubprojectRoots(root: string, opts?: {
85
85
  maxDepth?: number;
86
86
  max?: number;
87
87
  }): string[];
88
+ /**
89
+ * Does `prompt` contain an explicit structural keyword (English or CJK)? A
90
+ * keyword is a strong, self-contained signal, so the front-load hook fires on it
91
+ * directly — no graph check needed. (A *code-token* match, by contrast, is only
92
+ * a candidate the hook verifies against the graph first; see {@link extractCodeTokens}.)
93
+ */
94
+ export declare function hasStructuralKeyword(prompt: string): boolean;
95
+ /**
96
+ * Identifier-shaped tokens in `prompt` — camelCase / PascalCase-with-inner-cap,
97
+ * snake_case, a `name(` call, or the two sides of an `a.b` member access. Naming
98
+ * a symbol is a code question whatever the surrounding human language, and these
99
+ * shapes almost never occur in ordinary prose, so they catch the common
100
+ * "<symbol> 的调用链?" / "where is <symbol> 定義" prompts no keyword list would.
101
+ *
102
+ * These are *candidates*, not a verdict: a tech brand like `JavaScript` or
103
+ * `GitHub` is identifier-shaped too, so the front-load hook checks each token
104
+ * against the actual index ({@link getNodesByName}) and only fires when one is a
105
+ * real symbol here — otherwise a brand-name prompt would inject ~16KB of
106
+ * low-relevance context (issue #994 follow-up). A doc/data filename ("README.md")
107
+ * is excluded from the member-access form since it's a file reference, not a symbol.
108
+ */
109
+ export declare function extractCodeTokens(prompt: string): string[];
110
+ /**
111
+ * Cheap, graph-free candidate gate for the front-load hook: could `prompt` be a
112
+ * structural / flow / impact / "where-how" question worth front-loading context
113
+ * for? True on an explicit keyword (English or CJK, issue #994) OR an
114
+ * identifier-shaped token. A keyword is sufficient to fire on its own; a
115
+ * token-only match is only a candidate the hook then verifies against the graph
116
+ * (a brand name like `JavaScript` is token-shaped but isn't a symbol). Every
117
+ * non-candidate prompt ("fix this typo", in any language) stays a zero-cost no-op.
118
+ */
119
+ export declare function isStructuralPrompt(prompt: string): boolean;
88
120
  /**
89
121
  * What the front-load hook should do for a prompt issued from a directory.
90
122
  */
@@ -66,12 +66,24 @@ export declare function buildDefaultIgnore(rootDir: string): Ignore;
66
66
  */
67
67
  export declare class ScopeIgnore {
68
68
  private rootMatcher;
69
+ /**
70
+ * Project `codegraph.json` `exclude` patterns (#999), matched against the
71
+ * full root-relative path. Wins over everything else — an explicit user
72
+ * exclude applies even to tracked files and even inside embedded repos.
73
+ */
74
+ private exclude;
69
75
  private embedded;
70
76
  private defaults;
71
77
  constructor(rootMatcher: Ignore, embedded: Array<{
72
78
  root: string;
73
79
  matcher: Ignore;
74
- }>);
80
+ }>,
81
+ /**
82
+ * Project `codegraph.json` `exclude` patterns (#999), matched against the
83
+ * full root-relative path. Wins over everything else — an explicit user
84
+ * exclude applies even to tracked files and even inside embedded repos.
85
+ */
86
+ exclude?: Ignore | null);
75
87
  ignores(rel: string): boolean;
76
88
  }
77
89
  /**
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Parse worker pool — runs tree-sitter parsing across N worker threads so a full
3
+ * `codegraph index` uses every core instead of pinning one.
4
+ *
5
+ * Why this exists: `ExtractionOrchestrator.indexAll()` already reads files in
6
+ * parallel, but it parsed them through a SINGLE worker thread, so on an
7
+ * N-core machine indexing a large repo used one core and left the rest idle
8
+ * (issue #1015, the parse-time half of #320). Spreading the parse calls across a
9
+ * pool of workers — each its own tree-sitter WASM heap — restores multi-core
10
+ * throughput. SQLite storage stays on the main thread (it isn't thread-safe), so
11
+ * only the CPU-bound parse step is parallelised; results are stored as they
12
+ * arrive, in whatever order they finish.
13
+ *
14
+ * Design mirrors {@link ../mcp/query-pool} (idle-list dispatch, lazy growth,
15
+ * throttled cold-starts, crash recovery), with parse-specific behaviour:
16
+ * - per-worker recycle: WASM linear memory grows but never shrinks, so each
17
+ * worker is torn down and replaced after `recycleInterval` parses to reclaim
18
+ * its heap — the same reason the old single worker recycled.
19
+ * - reject, don't retry: a parse that crashes or times out its worker REJECTS
20
+ * (with a message the orchestrator's retry pass recognises) rather than being
21
+ * silently requeued — the orchestrator owns the smarter two-stage retry
22
+ * (fresh worker, then comment-stripped) on a clean WASM heap.
23
+ * - a size-1 pool reproduces the old single-worker path exactly, which is the
24
+ * conservative rollback: set `CODEGRAPH_PARSE_WORKERS=1`.
25
+ *
26
+ * Memory: peak scales with pool size (≈ size × a worker's pre-recycle heap), so
27
+ * the default is capped and the env var lets constrained machines dial it down.
28
+ */
29
+ import type { Language, ExtractionResult } from '../types';
30
+ /**
31
+ * Minimal worker surface the pool drives — satisfied by a real `worker_threads`
32
+ * Worker. Abstracted so tests can inject a fake worker and exercise the pool's
33
+ * queue / growth / recycle / crash-recovery logic without spawning threads or a
34
+ * built `dist/`.
35
+ */
36
+ export interface ParsePoolWorker {
37
+ postMessage(msg: unknown): void;
38
+ terminate(): Promise<number> | void;
39
+ on(event: 'message', cb: (m: unknown) => void): void;
40
+ on(event: 'error', cb: (e: Error) => void): void;
41
+ on(event: 'exit', cb: (code: number) => void): void;
42
+ }
43
+ /** A single file to parse. `language` is resolved on the main thread (it holds
44
+ * the project's codegraph.json extension overrides) and handed to the worker. */
45
+ export interface ParseTask {
46
+ filePath: string;
47
+ content: string;
48
+ language: Language;
49
+ frameworkNames?: string[];
50
+ }
51
+ /**
52
+ * Resolve the pool size from the `CODEGRAPH_PARSE_WORKERS` override and the
53
+ * machine's core count.
54
+ * - explicit `0` or `1` → 1 worker (the old single-worker path; the rollback).
55
+ * - explicit `N` → N, clamped to [1, 16].
56
+ * - unset / blank / non-numeric → `clamp(cores - 1, 1, 8)` (leave a core for
57
+ * the main thread + UI; never zero — parsing always needs a worker).
58
+ */
59
+ export declare function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number;
60
+ export interface ParseWorkerPoolOptions {
61
+ /** Languages to load grammars for in every worker at spawn. */
62
+ languages: Language[];
63
+ /** Number of worker threads (≥1). Clamp the resolved value before passing. */
64
+ size: number;
65
+ /** Compiled `parse-worker.js` path. Required unless `createWorker` is given. */
66
+ workerScriptPath?: string;
67
+ /** Parses per worker before recycle. Default 250. */
68
+ recycleInterval?: number;
69
+ /** Base per-parse timeout (ms); scaled by file size per parse. Default 10s. */
70
+ parseTimeoutMs?: number;
71
+ /** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
72
+ createWorker?: () => ParsePoolWorker;
73
+ /** Optional verbose logger (the orchestrator's `[worker] …` logger). */
74
+ log?: (msg: string) => void;
75
+ }
76
+ export declare class ParseWorkerPool {
77
+ private idle;
78
+ private queue;
79
+ private inflight;
80
+ private workers;
81
+ private pending;
82
+ private parseCounts;
83
+ private nextId;
84
+ private totalCrashes;
85
+ private destroyed;
86
+ private readonly languages;
87
+ private readonly maxSize;
88
+ private readonly recycleInterval;
89
+ private readonly parseTimeoutMs;
90
+ private readonly createWorker;
91
+ private readonly log;
92
+ constructor(opts: ParseWorkerPoolOptions);
93
+ /** Pool size cap (for logging). */
94
+ get size(): number;
95
+ /** Live worker count (for tests). */
96
+ get liveWorkers(): number;
97
+ /** False once the crash budget is exhausted (or after destroy). */
98
+ get healthy(): boolean;
99
+ /**
100
+ * Parse one file on the pool. Resolves with the extraction result, or REJECTS
101
+ * if the parse times out or its worker crashes — the caller records the error
102
+ * and (for worker-exit/OOM rejections) re-attempts in its retry pass.
103
+ */
104
+ requestParse(task: ParseTask): Promise<ExtractionResult>;
105
+ private spawnOne;
106
+ private onMessage;
107
+ /** A worker died (crash hook / OOM exit / spawn error). Reject its in-flight
108
+ * parse so the caller's retry pass can re-attempt it, then respawn. */
109
+ private onWorkerGone;
110
+ /** Tear down a worker that has hit its recycle threshold and replace it. Not a
111
+ * crash, so it doesn't count against the budget. */
112
+ private recycle;
113
+ private removeWorker;
114
+ private dispatch;
115
+ private onTimeout;
116
+ private drain;
117
+ private settle;
118
+ /**
119
+ * Recycle every idle worker now (fresh WASM heaps). The orchestrator calls
120
+ * this before its retry pass so crash-on-memory files get the cleanest heap.
121
+ */
122
+ recycleAll(): void;
123
+ /** Terminate all workers and reject any outstanding parses. */
124
+ destroy(): Promise<void>;
125
+ }
126
+ //# sourceMappingURL=parse-pool.d.ts.map
@@ -16,11 +16,38 @@
16
16
  * an absolute-path hash under `os.tmpdir()`. The pidfile always stays in the
17
17
  * project (it doesn't have a length limit) — and acts as the authoritative
18
18
  * pointer to the socket path the daemon chose.
19
+ *
20
+ * Second special-case (#997, #974): some filesystems can't host an AF_UNIX node
21
+ * AT ALL — ExFAT/FAT external volumes, certain network mounts, WSL2 DrvFs — so
22
+ * `listen()` throws ENOTSUP/EACCES regardless of path length. We can't cheaply
23
+ * tell those apart from a normal volume up front, so instead of guessing we
24
+ * expose an ORDERED candidate list (`getDaemonSocketCandidates`): the in-project
25
+ * path first, the deterministic tmpdir path as the fallback of last resort. The
26
+ * daemon binds the first that works (relocating past a capability error); the
27
+ * proxy connects the first that answers. Both walk the SAME list, so they still
28
+ * converge on whichever the daemon bound with zero coordination.
29
+ */
30
+ /**
31
+ * Ordered socket / named-pipe path candidates the daemon should try to bind (and
32
+ * the proxy should try to connect) for `projectRoot`, most-preferred first.
33
+ * Deterministic given a project root, so independent processes converge without
34
+ * coordination — even when the preferred candidate is unusable and both fall
35
+ * through to the same fallback.
36
+ *
37
+ * - Windows: a single named pipe (lives in the kernel pipe namespace, not on
38
+ * the project FS, so neither the length nor the ExFAT hazard applies).
39
+ * - Short in-project path: `[ .codegraph/daemon.sock , <tmpdir> ]` — try the
40
+ * project first, fall back to tmpdir if its FS can't host a socket (#997).
41
+ * - Long in-project path (deep monorepos, Bazel out dirs): `[ <tmpdir> ]` only
42
+ * — bind would throw ENAMETOOLONG, so we skip straight to tmpdir.
19
43
  */
44
+ export declare function getDaemonSocketCandidates(projectRoot: string): string[];
20
45
  /**
21
- * Compute the socket / named-pipe path the daemon should listen on (and the
22
- * proxy should connect to) for `projectRoot`. Deterministic given a project
23
- * root, so independent processes converge without coordination.
46
+ * The PREFERRED (primary) socket path candidate 0. Use this only where a
47
+ * single representative path is wanted (the lockfile's informational
48
+ * `socketPath` field, status display). For binding/connecting, walk the full
49
+ * {@link getDaemonSocketCandidates} list — the daemon may bind a fallback when
50
+ * candidate 0 is unusable.
24
51
  */
25
52
  export declare function getDaemonSocketPath(projectRoot: string): string;
26
53
  /** Absolute path to the daemon pid lockfile for `projectRoot`. */
@@ -39,6 +39,7 @@
39
39
  * - The decision of *whether* to run as daemon at all — that's `MCPServer`.
40
40
  * - The MCP protocol state machine — that's `./session.ts`.
41
41
  */
42
+ import * as net from 'net';
42
43
  import { DaemonLockInfo } from './daemon-paths';
43
44
  /** Bytes/parse-window for an oversized hello line — bounded against a malicious peer. */
44
45
  declare const MAX_HELLO_LINE_BYTES = 4096;
@@ -177,8 +178,29 @@ export type AcquireResult = {
177
178
  * the pidfile becomes visible in one step already containing a full record.
178
179
  * Whoever links first wins; everyone else gets EEXIST and reads a complete file.
179
180
  * There is no empty-file window at all.
181
+ *
182
+ * Filesystems without hard links (#997): ExFAT/FAT external volumes and some
183
+ * network mounts can't `link()` at all — it throws ENOTSUP/EPERM, which would
184
+ * otherwise kill the daemon before it ever reaches the socket bind. There we
185
+ * fall back to an O_EXCL create (`acquireLockViaExclusiveOpen`): still exclusive
186
+ * ("first writer wins"), but the full record is written through the fd in a
187
+ * second step, so the empty-file window the link approach removed is reopened —
188
+ * only on these filesystems, only for the microseconds between create and write
189
+ * (far narrower than the original bug, which the file watcher's startup latency
190
+ * widened). The race's worst case is two daemons briefly; on a single external
191
+ * drive that's strictly better than the daemon never starting at all.
180
192
  */
181
193
  export declare function tryAcquireDaemonLock(projectRoot: string): AcquireResult;
194
+ /**
195
+ * Exclusive-create the pidfile (O_CREAT|O_EXCL via the `wx` flag) and write the
196
+ * full record through the same fd — the hard-link-free fallback used by
197
+ * {@link tryAcquireDaemonLock} on filesystems without `link()`. Returns true if
198
+ * we created it (acquired the lock), false on EEXIST (another candidate holds
199
+ * it). Any other error propagates. Still exclusive, so "first writer wins" holds
200
+ * exactly as the link path does; the only difference is the brief empty-file
201
+ * window between create and write. Exported for testing.
202
+ */
203
+ export declare function acquireLockViaExclusiveOpen(pidPath: string, info: DaemonLockInfo): boolean;
182
204
  /**
183
205
  * Remove a stale pidfile, but only if it still names a dead process. Re-reads
184
206
  * the file immediately before unlinking so we never delete a lock that a live
@@ -197,6 +219,22 @@ export declare function clearStaleDaemonLock(pidPath: string, expectedDeadPid?:
197
219
  * mistake a live daemon for a dead one and clear its lock.
198
220
  */
199
221
  export declare function isProcessAlive(pid: number): boolean;
222
+ /**
223
+ * Bind the first usable socket from an ordered candidate list, relocating past
224
+ * any path that fails to bind for a non-conflict reason (see {@link
225
+ * SOCKET_BIND_CONFLICT_CODE}). The injected `listen` does the real
226
+ * `net.Server.listen` (and stale-socket clear); abstracted so the relocation
227
+ * policy is unit-testable without a real unsupported filesystem. Returns the
228
+ * server plus the path actually bound. An EADDRINUSE, or any error on the LAST
229
+ * candidate, propagates — the caller releases the lockfile and falls back to
230
+ * direct mode (#974). Exported for testing.
231
+ */
232
+ export declare function bindFirstUsableSocket(candidates: string[], listen: (socketPath: string) => Promise<net.Server>, opts?: {
233
+ onRelocate?: (from: string, to: string, code: string) => void;
234
+ }): Promise<{
235
+ server: net.Server;
236
+ socketPath: string;
237
+ }>;
200
238
  /**
201
239
  * Parse one client-hello line. Returns the peer pids if `line` is a well-formed
202
240
  * client-hello (carries the `codegraph_client` marker), or null otherwise — in
@@ -17,6 +17,15 @@ export interface MCPEngineOptions {
17
17
  * cheap. Honors {@link watchDisabledReason} regardless.
18
18
  */
19
19
  watch?: boolean;
20
+ /**
21
+ * Whether to off-load read-tool dispatch to a worker-thread pool. Only the
22
+ * SHARED daemon wants this — it serves many concurrent clients on one event
23
+ * loop, so without a pool concurrent explores serialize and starve the MCP
24
+ * transport. Direct mode (one stdio client, no concurrency) leaves it off so a
25
+ * single call never pays a worker round-trip. `CODEGRAPH_QUERY_POOL_SIZE=0`
26
+ * disables it even in daemon mode.
27
+ */
28
+ queryPool?: boolean;
20
29
  }
21
30
  /**
22
31
  * Shared MCP engine. Thread-safe in the sense that multiple sessions can
@@ -32,7 +41,15 @@ export declare class MCPEngine {
32
41
  private watcherStarted;
33
42
  private opts;
34
43
  private closed;
44
+ private queryPool;
35
45
  constructor(opts?: MCPEngineOptions);
46
+ /**
47
+ * Start the worker-thread query pool once a default project is open (daemon
48
+ * mode only; honors `CODEGRAPH_QUERY_POOL_SIZE`). Idempotent and best-effort:
49
+ * if workers can't spawn on this platform the ToolHandler keeps serving reads
50
+ * in-process, so the pool can only help, never break, tool calls.
51
+ */
52
+ private maybeStartPool;
36
53
  /**
37
54
  * Convenience for {@link MCPServer} compatibility: pre-seed an explicit
38
55
  * project path (from the `--path` CLI flag) without yet opening it. This
@@ -41,4 +41,22 @@ export interface SupervisionState {
41
41
  * supervisor and should shut down, or null while it is still supervised.
42
42
  */
43
43
  export declare function supervisionLostReason(state: SupervisionState): string | null;
44
+ /** Default PPID poll cadence (ms). Shared by the MCP server and CLI commands. */
45
+ export declare const DEFAULT_PPID_POLL_MS = 5000;
46
+ /**
47
+ * Resolve the PPID watchdog poll interval from an env override
48
+ * (`CODEGRAPH_PPID_POLL_MS`). A value of `0` disables the watchdog entirely
49
+ * (escape hatch for embedded scenarios where the parent legitimately re-parents
50
+ * the process on purpose). Anything non-numeric or negative falls back to the
51
+ * default.
52
+ */
53
+ export declare function parsePpidPollMs(raw: string | undefined): number;
54
+ /**
55
+ * Parse the host PID propagated across the `--liftoff-only` re-exec
56
+ * (`CODEGRAPH_HOST_PPID`). Returns a positive integer PID, or null when
57
+ * unset/invalid — the direct-launch path, where the watchdog falls back to
58
+ * `process.ppid` divergence. PIDs of 0/1 are rejected (0 = unknown, 1 = init,
59
+ * i.e. already orphaned), so the watchdog doesn't latch onto init.
60
+ */
61
+ export declare function parseHostPpid(raw: string | undefined): number | null;
44
62
  //# sourceMappingURL=ppid-watchdog.d.ts.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Query pool — runs CPU-heavy read-tool calls on a pool of worker threads so
3
+ * the shared daemon's main event loop stays free for the MCP transport.
4
+ *
5
+ * Why this exists: see {@link ./query-worker}. One daemon, one event loop, one
6
+ * synchronous SQLite connection serializes every concurrent `codegraph_explore`
7
+ * AND starves the transport (a 10-way wave delivered 0 transport heartbeats in
8
+ * 25s — responses can't flush until the whole batch drains, so clients time
9
+ * out). Spreading the dispatch across worker threads (each its own WAL read
10
+ * connection) restores true multi-core parallelism and an idle main loop.
11
+ *
12
+ * Properties:
13
+ * - lazy growth: one warm worker on construct, grows to `size` on demand, so a
14
+ * single-agent session pays for one connection and a 10-subagent burst grows
15
+ * to the core budget.
16
+ * - crash recovery: a dead worker is respawned and its in-flight call retried
17
+ * once; a poison call that keeps crashing fails gracefully (never wedges the
18
+ * pool). A crash budget trips a circuit breaker (`healthy` → false) so the
19
+ * caller falls back to in-process dispatch instead of thrashing respawns.
20
+ * - graceful backstop: a call that can't be served within `softTimeoutMs`
21
+ * resolves with SUCCESS-shaped "busy, retry" guidance — never `isError`, so
22
+ * a momentary overload can't teach the agent to abandon codegraph — instead
23
+ * of hanging past the client's hard timeout.
24
+ */
25
+ import type { ToolResult } from './tools';
26
+ /**
27
+ * Minimal worker surface the pool drives — satisfied by a real `worker_threads`
28
+ * Worker. Abstracted so tests can inject a fake worker and exercise the pool's
29
+ * queue / growth / crash-recovery / backstop logic without spawning threads or
30
+ * needing a built `dist/`.
31
+ */
32
+ export interface PoolWorker {
33
+ postMessage(msg: unknown): void;
34
+ terminate(): Promise<number> | void;
35
+ on(event: 'message', cb: (m: unknown) => void): void;
36
+ on(event: 'error', cb: (e: Error) => void): void;
37
+ on(event: 'exit', cb: (code: number) => void): void;
38
+ }
39
+ export interface QueryPoolOptions {
40
+ /** Default project root each worker opens at spawn. */
41
+ root: string;
42
+ /** Max worker threads. Defaults to `clamp(cores-1, 1, 16)`. */
43
+ size?: number;
44
+ /** Linger before a queued call gets busy-guidance. Default 45s. */
45
+ softTimeoutMs?: number;
46
+ /** Retries for an in-flight call whose worker crashed. Default 1. */
47
+ maxRetries?: number;
48
+ /** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
49
+ createWorker?: () => PoolWorker;
50
+ }
51
+ /**
52
+ * Resolve the pool size from the `CODEGRAPH_QUERY_POOL_SIZE` override and the
53
+ * machine's core count. `0` (or a negative) explicitly disables the pool (the
54
+ * caller serves in-process — today's behavior). Unset → `clamp(cores-1, 1, 16)`:
55
+ * leave a core for the main loop + OS, but never zero, since even one worker
56
+ * frees the transport and lets responses flush incrementally.
57
+ */
58
+ export declare function resolvePoolSize(envVal: string | undefined, cpuCount: number): number;
59
+ export declare class QueryPool {
60
+ private idle;
61
+ private queue;
62
+ private inflight;
63
+ private workers;
64
+ private pendingWorkers;
65
+ private nextId;
66
+ private totalCrashes;
67
+ private destroyed;
68
+ private readonly root;
69
+ private readonly maxSize;
70
+ private readonly softTimeoutMs;
71
+ private readonly maxRetries;
72
+ private readonly createWorker;
73
+ constructor(opts: QueryPoolOptions);
74
+ /** Pool size cap (for logging/status). */
75
+ get size(): number;
76
+ /** Live worker count (for tests/status). */
77
+ get liveWorkers(): number;
78
+ /**
79
+ * False once the crash budget is exhausted (or after destroy). The ToolHandler
80
+ * checks this and falls back to in-process dispatch — a broken worker platform
81
+ * degrades to today's behavior instead of failing tool calls.
82
+ */
83
+ get healthy(): boolean;
84
+ private spawnOne;
85
+ private onMessage;
86
+ private onWorkerGone;
87
+ private drain;
88
+ private settle;
89
+ /** Run a read tool on the pool. Always resolves (never rejects). */
90
+ run(toolName: string, args: Record<string, unknown>): Promise<ToolResult>;
91
+ /** Terminate all workers and answer any outstanding calls gracefully. */
92
+ destroy(): Promise<void>;
93
+ }
94
+ //# sourceMappingURL=query-pool.d.ts.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Query worker thread — issue: concurrent MCP tool calls starve the daemon.
3
+ *
4
+ * The shared daemon serves every session on ONE event loop with synchronous
5
+ * `node:sqlite`. `codegraph_explore` is CPU-heavy (FTS + RWR/personalized-
6
+ * PageRank + impact + output building) stitched together by microtask `await`s,
7
+ * so N concurrent explores keep the microtask queue continuously full and
8
+ * starve the macrotask phases — timers AND socket I/O. The transport freezes:
9
+ * no response flushes, no request is read, until the whole batch drains. With
10
+ * ~10 subagents that routinely exceeds the MCP client's request timeout.
11
+ *
12
+ * This worker moves the heavy read-tool dispatch OFF the daemon's main loop.
13
+ * Each worker owns its OWN read connection (node:sqlite WAL allows N concurrent
14
+ * readers across connections — verified: a worker reader sees the main writer's
15
+ * committed catch-up/watcher writes), so {@link QueryPool} runs N tool calls in
16
+ * true parallel up to core count while the main loop stays free for the MCP
17
+ * transport. The worker runs {@link ToolHandler.executeReadTool} — validation +
18
+ * dispatch + error classification — and returns the raw {@link ToolResult}; the
19
+ * MAIN thread keeps the catch-up gate, the watcher-state notices (staleness /
20
+ * worktree), `codegraph_status`, and telemetry, none of which a watcher-less
21
+ * read connection can answer.
22
+ */
23
+ export {};
24
+ //# sourceMappingURL=query-worker.d.ts.map
@@ -4,6 +4,7 @@
4
4
  * Defines the tools exposed by the CodeGraph MCP server.
5
5
  */
6
6
  import type CodeGraph from '../index';
7
+ import type { QueryPool } from './query-pool';
7
8
  import type { PendingFile } from '../sync';
8
9
  /**
9
10
  * An expected, recoverable "codegraph can't serve this" condition — most
@@ -113,6 +114,33 @@ export interface ToolDefinition {
113
114
  properties: Record<string, PropertySchema>;
114
115
  required?: string[];
115
116
  };
117
+ /** Behavioral hints for clients (see {@link ToolAnnotations}). */
118
+ annotations?: ToolAnnotations;
119
+ }
120
+ /**
121
+ * MCP ToolAnnotations — behavioral hints a client MAY use to decide how, or
122
+ * whether, to run a tool (introduced in the 2025-03-26 spec, carried in
123
+ * 2025-06-18). They are advisory and never to be trusted for security, but
124
+ * clients gate on them: Cursor's Ask mode, for one, refuses any MCP tool that
125
+ * doesn't advertise `readOnlyHint: true` (issue #1018).
126
+ *
127
+ * The field is purely additive — a client that predates annotations ignores it
128
+ * — so codegraph advertises these even though `initialize` still negotiates the
129
+ * 2024-11-05 protocol version.
130
+ *
131
+ * https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations
132
+ */
133
+ export interface ToolAnnotations {
134
+ /** Human-readable title for the tool. */
135
+ title?: string;
136
+ /** If true, the tool does not modify its environment. Default (unset): false. */
137
+ readOnlyHint?: boolean;
138
+ /** Meaningful only when NOT read-only: may the tool perform destructive updates? */
139
+ destructiveHint?: boolean;
140
+ /** If true, repeat calls with the same arguments have no additional effect. */
141
+ idempotentHint?: boolean;
142
+ /** If true, the tool interacts with an open world of external entities. */
143
+ openWorldHint?: boolean;
116
144
  }
117
145
  interface PropertySchema {
118
146
  type: string;
@@ -159,7 +187,15 @@ export declare class ToolHandler {
159
187
  private defaultProjectHint;
160
188
  private worktreeMismatchCache;
161
189
  private catchUpGate;
190
+ private queryPool;
162
191
  constructor(cg: CodeGraph | null);
192
+ /**
193
+ * Engine-only: attach (or detach with null) the worker-thread query pool. The
194
+ * shared daemon sets this once its default project is open; the workers each
195
+ * hold their own WAL read connection and run {@link executeReadTool}. A
196
+ * worker's own ToolHandler never has a pool, so there is no nested off-loading.
197
+ */
198
+ setQueryPool(pool: QueryPool | null): void;
163
199
  /**
164
200
  * Update the default CodeGraph instance (e.g. after lazy initialization)
165
201
  */
@@ -286,6 +322,27 @@ export declare class ToolHandler {
286
322
  * Execute a tool by name
287
323
  */
288
324
  execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult>;
325
+ /**
326
+ * Run a single read tool to completion and return its raw {@link ToolResult},
327
+ * classifying expected failures the same way {@link execute}'s catch does so
328
+ * the SHAPE is identical whether dispatch runs in-process or on a worker:
329
+ * NotIndexed → success-shaped guidance, PathRefusal → clean error, anything
330
+ * else → internal-error-with-retry. Never throws.
331
+ *
332
+ * This is the worker thread's entry point (see {@link ./query-worker}) and the
333
+ * in-process fallback for {@link execute}. It deliberately does NOT run the
334
+ * catch-up gate or the staleness/worktree notices — those need the daemon's
335
+ * watched main instance and stay on the main thread. Cross-cutting allowlist +
336
+ * path validation already ran in {@link execute} before routing here.
337
+ */
338
+ executeReadTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult>;
339
+ /**
340
+ * Pure dispatch over the read tools — the switch, with no gate, no notices, no
341
+ * allowlist/validation (the caller owns those). `codegraph_status` is handled
342
+ * on the main thread in {@link execute} and never reaches here. May throw
343
+ * NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
344
+ */
345
+ private dispatchTool;
289
346
  /**
290
347
  * Handle codegraph_search
291
348
  */
@@ -12,6 +12,17 @@ export interface ProjectConfig {
12
12
  * are never discovered or indexed (#970, #976).
13
13
  */
14
14
  includeIgnored?: string[];
15
+ /**
16
+ * Gitignore-style patterns for paths to keep OUT of the index — even when
17
+ * they are git-TRACKED, which `.gitignore` cannot do (#999). The escape hatch
18
+ * for a committed vendor/theme/SDK directory (e.g. a checked-in Metronic theme
19
+ * under `static/`) that bloats the graph and slows indexing but isn't really
20
+ * your code. Matched against project-root-relative paths, so a directory like
21
+ * `"static/"`, a double-star vendor glob, or `"assets/theme"` all work.
22
+ * Absent/empty (the default) excludes nothing beyond the built-in defaults
23
+ * and your `.gitignore`.
24
+ */
25
+ exclude?: string[];
15
26
  }
16
27
  /**
17
28
  * Load the validated extension overrides for a project, mtime-cached.
@@ -31,6 +42,15 @@ export declare function loadExtensionOverrides(rootDir: string): Record<string,
31
42
  * are never discovered or indexed (#970, #976).
32
43
  */
33
44
  export declare function loadIncludeIgnoredPatterns(rootDir: string): string[];
45
+ /**
46
+ * Load the validated `exclude` patterns for a project, mtime-cached.
47
+ *
48
+ * These name paths to keep OUT of the index even when git-tracked — the escape
49
+ * hatch for a committed vendor/theme/SDK directory `.gitignore` can't drop
50
+ * (#999). An empty result — the zero-config default — excludes nothing beyond
51
+ * the built-in defaults and the project's `.gitignore`.
52
+ */
53
+ export declare function loadExcludePatterns(rootDir: string): string[];
34
54
  /** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */
35
55
  export declare function clearProjectConfigCache(): void;
36
56
  //# sourceMappingURL=project-config.d.ts.map
@@ -1,31 +1,3 @@
1
- /**
2
- * C/C++ function-pointer dispatch synthesis (#932).
3
- *
4
- * C/C++ polymorphism is the function pointer: a struct carries a fn-pointer
5
- * field (`int (*fn)(int)`, or a fn-pointer-typedef field `hook_func func`),
6
- * concrete functions are *registered* into it through a table
7
- * (`static struct cmd cmds[] = {{"add", cmd_add}, …}`, a designated
8
- * `.fn = cmd_add`, or `x->fn = cmd_add`), and the dispatcher calls through it
9
- * indirectly (`p->fn(argv)`). Static extraction captures neither the
10
- * registration→field binding nor the indirect call, so the dispatcher→handler
11
- * edge is missing and `git`'s `run_builtin` looks like it calls nothing, the
12
- * hooks in `hook_demo.c` are unreachable, etc.
13
- *
14
- * This bridges it, keyed by **(struct type, fn-pointer field)**:
15
- * • registrations — a function bound to `S.field` via a positional
16
- * initializer (matched by field index), a designated `.field = fn`, or a
17
- * direct `x.field = fn` / `x->field = fn` assignment;
18
- * • dispatch — `recv->field(…)` / `recv.field(…)` where `recv` resolves to a
19
- * value of struct type `S` (from the enclosing function's params / locals),
20
- * falling back to the field name when it is unique to one struct;
21
- * • field←field propagation — `a->f = b->g` merges `B.g`'s handlers into
22
- * `A.f`, so a generic single-slot hook that is reassigned from a registry
23
- * (the `hook_demo.c` shape: `h->func = found->fn`) still resolves.
24
- *
25
- * Whole-graph pass after base resolution; all edges are `provenance:'heuristic'`
26
- * (`synthesizedBy:'fn-pointer-dispatch'`). High precision via the (type, field)
27
- * key + a real-function gate; a project with no fn-pointer dispatch is a no-op.
28
- */
29
1
  import type { Edge } from '../types';
30
2
  import type { QueryBuilder } from '../db/queries';
31
3
  import type { ResolutionContext } from './types';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colbymchenry/codegraph",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Local-first code intelligence for AI agents (MCP). Self-contained — bundles its own runtime.",
5
5
  "bin": {
6
6
  "codegraph": "npm-shim.js"
@@ -15,12 +15,12 @@
15
15
  "./package.json": "./package.json"
16
16
  },
17
17
  "optionalDependencies": {
18
- "@colbymchenry/codegraph-darwin-arm64": "1.1.1",
19
- "@colbymchenry/codegraph-darwin-x64": "1.1.1",
20
- "@colbymchenry/codegraph-linux-arm64": "1.1.1",
21
- "@colbymchenry/codegraph-linux-x64": "1.1.1",
22
- "@colbymchenry/codegraph-win32-arm64": "1.1.1",
23
- "@colbymchenry/codegraph-win32-x64": "1.1.1"
18
+ "@colbymchenry/codegraph-darwin-arm64": "1.1.2",
19
+ "@colbymchenry/codegraph-darwin-x64": "1.1.2",
20
+ "@colbymchenry/codegraph-linux-arm64": "1.1.2",
21
+ "@colbymchenry/codegraph-linux-x64": "1.1.2",
22
+ "@colbymchenry/codegraph-win32-arm64": "1.1.2",
23
+ "@colbymchenry/codegraph-win32-x64": "1.1.2"
24
24
  },
25
25
  "files": [
26
26
  "npm-shim.js",