diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec4856d7c..b8c1d56e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,9 +108,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Java calls inside an anonymous class's constructor arguments are recorded. In `new Base(build()) { … }`, the `build()` call and any `new Helper()` in the arguments belonged to neither the enclosing method nor the anonymous class. They now belong to the enclosing method, as they do without a class body. - A `.mdx` path in a code string links to the `.mdx` file. `'docs/intro.mdx'` was read as `docs/intro.md`, so the reference pointed at a file that usually does not exist. Two resolver details are also tightened: the supertype order used to resolve `this.member` no longer depends on SQLite's query plan, and a file that shrank between indexing and resolution can no longer make a field lookup read past its end. - TypeScript and JavaScript receivers are typed from the code that declares them. A parameter named like a module-level factory (`useIt(make: () => Other)` beside `function make(): Engine`) hides it, so neither `make()` nor `make().run()` links to the factory or `Engine`. An awaited result (`await load()`) and an imported instance (`store.notify()`) are typed in the file that declares them, not by a same-named class in the caller's file. A `/\}/` regex in an object literal no longer ends the literal early, and a `return` inside a callback no longer types the function around it. In a React or other framework project, `api.getState()` on `{ getState: wrong, ...override }` no longer picks `wrong`. Same-line overloads (`function y() {}; function y(n) {}`) each keep their own binding, a guard lookup finds `target` in `if(ok)target()`, and a `window.app.stop()` call split across lines is recorded as `window.app.stop`. -- Claude Code connects to CodeGraph without waiting on the shared daemon. It sends a `server/discover` probe before `initialize` and holds the handshake until it gets an answer. The probe went to the daemon, so every session start waited on the daemon connection, and when the daemon was unavailable the probe waited about 6 s and failed with an internal error. It is now answered at once with method-not-found, the reply that tells a client to use `initialize`. Measured: 6.2 s to 0.1 s with the daemon unavailable, 207 ms to 84 ms when a new daemon starts. +- Claude Code connects to CodeGraph without waiting on the shared daemon. It sends a `server/discover` probe before `initialize` and holds the handshake until it gets an answer. The probe went to the daemon, so every session start waited on the daemon connection, and when the daemon was unavailable the probe waited about 6 s and failed with an internal error. It is now answered at once with method-not-found, the reply that tells a client to use `initialize`. Measured: 6.2 s to 0.1 s with the daemon unavailable, 207 ms to 84 ms when a new daemon starts. Antigravity 2.5 and other newer clients send the same probe and get the same answer. (#2084) - An explore answer given while the index lags behind edited files reaches Claude Code in full. The answer also carried the stale-file list as `structuredContent`, and Claude Code shows the model a result's `structuredContent` in place of its text, so the agent saw only that list. The file list now rides only in the text, which names every file it could not check where it used to stop at 20; `codegraph_status` drops the same field for the same reason. -- A project queried by `projectPath` is now released after 10 minutes without a query. The MCP server that opened it holds that project's writer lock while it stays open, so a long-running daemon for one checkout could keep another checkout's own daemon from starting until the daemon exited. The next query reopens the project and catches up. `CODEGRAPH_PROJECT_IDLE_RELEASE_MS` changes the delay; `0` keeps projects open as before. - A search written as separate words now finds the identifier they spell. `add url rule` ranks `add_url_rule` first instead of sixth behind the one-word `add` functions, and `svelte map` finds the `SvelteMap` class, which it missed entirely. A one-word query also finds its base form: `mounting` finds `mount`. - A search for a name the whole project imports now finds the definition. On Flask, `codegraph query Flask` listed `flask` imports and missed `class Flask` below `--limit 60`: the exact-name lookup kept the first 20 case-insensitive matches, and every one was an import. It now takes definitions first, then exact case, and the class ranks first at the default limit. @@ -401,6 +400,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A Go call on the result of an imported package's function, such as `bbb.New().Bar()`, links to `Bar` on the type that `bbb.New` returns. It used to link by the bare name `Bar`, often to a same-named method in another package. A package from outside the module, like `xml.NewEncoder(w).Encode(v)` or `http.FileServer(fs).ServeHTTP(w, r)`, now gets no link instead of a project method that shares the name. Re-index to pick this up. - Three JavaScript and TypeScript links now reach the code that runs. `setTimeout(this.create)` in a class that implements an interface declaring `create` and inherits a `create` body links to the inherited body, not the interface's declaration; the declaration is still linked when no class declares the member. A default export links to the exported declaration rather than an earlier function of the same name nested inside another function. A re-export chain that comes back into a file for a different name (`export { default as Card } from './card.js'` where `card.js` re-exports that file's `real` as its default) now links the call to `real` instead of dropping it. Re-index JavaScript and TypeScript projects after upgrading. - Four names are now read where they are used. A PHP or TypeScript receiver typed `Sub` no longer inherits methods from the parent of a different `Sub` in another namespace or module; the walk follows the `Sub` the file names. In Kotlin, `w.let { it.render() }` no longer types `it` as `w` when `w`'s class declares its own `let`, since the member, not the standard-library function, decides what the lambda receives. A C `#define ENABLE_TRACE BACKING` now reads `BACKING` where `#if ENABLE_TRACE` is evaluated, not where the alias was defined, so redefining `BACKING` before a second `#include` changes which macros the header defines. In Python, an assignment inside a nested `def` no longer changes the type of the enclosing function's variable of the same name. Re-index to pick this up. +- On Windows, terminal windows no longer flash open and closed while CodeGraph runs in the background. Since 1.6.1, a background MCP server popped up a console window (a full Windows Terminal window when that is the default terminal) several times when it started and again every time it re-synced a changed file. All of CodeGraph's git calls now run hidden. Thanks @Suharaz, @23q3, @A-Van-Gestel and @HarryMuc. (#2094, #2096) +- The Claude Code prompt hook no longer runs on the messages Claude Code uses to hand a subagent's report back to the main session. Before, such a long report could keep the hook busy past Claude Code's 30-second hook timeout and inject context unrelated to what you asked. Thanks @danusha2345, and @tippmar-nr for the report. (#2184) +- A CodeGraph session that queried another project through `projectPath` no longer keeps that project locked for as long as it runs: after 10 minutes without a query it lets the project go, so the project's own session and `codegraph index` can take over again (tune with `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`, `0` keeps it open). Thanks @danusha2345, and @bompus for the report. (#2087) ## [1.6.1] - 2026-09-29 diff --git a/README.md b/README.md index 369b97a21d..feeb8fa1f3 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. ## About this fork -This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains all of upstream `main` (last merged: [`ec738ec7`](https://github.com/colbymchenry/codegraph/commit/ec738ec7a322a680dae39a904194ce9271e6873f), after v1.6.1, 2026-10-01) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. +This is **bompus/codegraph**, a fork of [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph). Its default branch, `fork/consolidated`, contains upstream `main` through [`fdc7754a`](https://github.com/colbymchenry/codegraph/commit/fdc7754a72065ac9a06702aa32ee3bf116a0658f) (after v1.6.1, merged 2026-10-02) plus the fork's own work, and it takes upstream changes as they land. Changes that suit upstream are also offered there as pull requests. The fork publishes no releases. The install scripts, npm package, badges and `codegraph upgrade` further down this page install **upstream's** releases. To run the fork, build it from source (below). @@ -786,7 +786,7 @@ When running as an MCP server, CodeGraph exposes **one tool for code** — `code The other tools (`codegraph_node`, `codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) stay fully functional but **unlisted by default** — everything they return already arrives inline on `codegraph_explore` (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the `CODEGRAPH_MCP_TOOLS` environment variable (e.g. `CODEGRAPH_MCP_TOOLS=explore,node,search,callers`), or use their CLI equivalents (`codegraph node` / `query` / `callers` / `callees` / `impact` / `files` / `status`). -Even when the server's own root has no `.codegraph/` index, the tools stay available: pass `projectPath` to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision. A project opened this way is watched and kept in sync while the session uses it, and released after 10 minutes without a query (`CODEGRAPH_PROJECT_IDLE_RELEASE_MS`; `0` keeps it open). +Even when the server's own root has no `.codegraph/` index, the tools stay available: pass `projectPath` to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision. A project opened this way is watched and kept in sync while the session uses it, and released after 10 minutes without a query (`CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`; `0` keeps it open). --- diff --git a/__tests__/child-process-windows-hide.test.ts b/__tests__/child-process-windows-hide.test.ts new file mode 100644 index 0000000000..adb7056b76 --- /dev/null +++ b/__tests__/child-process-windows-hide.test.ts @@ -0,0 +1,208 @@ +/** + * Static source guard (all platforms): every child process spawned from `src/` + * sets `windowsHide: true`. + * + * The MCP daemon is spawned detached and has no console of its own. On + * Windows, a console program (git, …) it starts without windowsHide gets a + * brand-new visible console, so a terminal window flashes on screen and closes + * once per call: on daemon start and again on every auto-sync (#485, #928, + * #1092, #2094, #2096). With Windows Terminal set as the default terminal, each + * flash is a full Windows Terminal window. + * + * windowsHide is Windows-only behavior the POSIX test runs can't observe, so it + * is asserted on the TypeScript AST. Options passed through a variable or a + * function parameter are traced back to the object literal that defines them + * (for a parameter: through every call site of that function in the file). + */ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import ts from 'typescript'; + +const SRC_DIR = path.join(__dirname, '..', 'src'); + +/** + * child_process functions that start a process and accept `windowsHide`, by + * argument shape: `file` is `(file, args?, options?)`, `command` is + * `(command, options?)`. + */ +const SPAWNERS: Record = { + exec: 'command', + execSync: 'command', + execFile: 'file', + execFileSync: 'file', + spawn: 'file', + spawnSync: 'file', + fork: 'file', +}; + +/** + * Spawns that intentionally run without windowsHide, keyed by + * `:()`, valued by the reason. + */ +const ALLOWED: Record = { + "resolution/memory-budget.ts:execFileSync('/usr/bin/vm_stat')": 'macOS only: guarded by process.platform === darwin', + 'ui-server/open-browser.ts:spawn(open.command)': + 'detached: true maps to DETACHED_PROCESS on Windows, so `cmd /c start` gets no console to show', +}; + +function listSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...listSourceFiles(p)); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(p); + } + return out; +} + +/** Local names bound to child_process exports in one file. */ +interface ChildProcessBindings { + /** Local name → imported child_process export (`import { spawn as s }`). */ + named: Map; + /** Namespace aliases (`import * as cp from 'child_process'`). */ + namespaces: Set; +} + +function childProcessBindings(sf: ts.SourceFile): ChildProcessBindings { + const named = new Map(); + const namespaces = new Set(); + for (const stmt of sf.statements) { + if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue; + if (!['child_process', 'node:child_process'].includes(stmt.moduleSpecifier.text)) continue; + const bindings = stmt.importClause?.namedBindings; + if (!bindings) continue; + if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text); + else for (const el of bindings.elements) named.set(el.name.text, (el.propertyName ?? el.name).text); + } + return { named, namespaces }; +} + +/** The child_process function a call invokes, or null when it isn't one. */ +function spawnerOf(call: ts.CallExpression, b: ChildProcessBindings): string | null { + const callee = call.expression; + const name = ts.isIdentifier(callee) + ? b.named.get(callee.text) + : ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && b.namespaces.has(callee.expression.text) + ? callee.name.text + : undefined; + return name !== undefined && Object.hasOwn(SPAWNERS, name) ? name : null; +} + +/** The options argument of a spawner call, or undefined when none is passed. */ +function optionsArg(call: ts.CallExpression, spawner: string): ts.Expression | undefined { + const [, second, third] = call.arguments; + if (SPAWNERS[spawner] === 'command') return second; + // (file, options) is allowed when the args array is omitted. + if (second && ts.isObjectLiteralExpression(second)) return second; + return third; +} + +function unwrap(expr: ts.Expression): ts.Expression { + let e = expr; + while (ts.isAsExpression(e) || ts.isParenthesizedExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression; + return e; +} + +/** Variable initializer or function parameter that `name` refers to at `from`. */ +function resolveBinding(name: string, from: ts.Node): ts.VariableDeclaration | ts.ParameterDeclaration | null { + for (let scope: ts.Node | undefined = from.parent; scope; scope = scope.parent) { + if (ts.isFunctionLike(scope)) { + const param = scope.parameters.find((p) => ts.isIdentifier(p.name) && p.name.text === name); + if (param) return param; + } + if (ts.isBlock(scope) || ts.isSourceFile(scope) || ts.isModuleBlock(scope)) { + for (const stmt of scope.statements) { + if (!ts.isVariableStatement(stmt)) continue; + const decl = stmt.declarationList.declarations.find((d) => ts.isIdentifier(d.name) && d.name.text === name); + if (decl) return decl; + } + } + } + return null; +} + +/** Calls to the function that declares `param`, found by name in the same file. */ +function callSitesOf(param: ts.ParameterDeclaration, sf: ts.SourceFile): ts.Expression[] | null { + const fn = param.parent; + const fnName = ts.isFunctionDeclaration(fn) && fn.name ? fn.name.text : null; + if (!fnName) return null; + const index = fn.parameters.indexOf(param); + const args: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === fnName) { + const arg = node.arguments[index]; + if (arg) args.push(arg); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return args.length > 0 ? args : null; +} + +/** True when `expr` provably evaluates to options with `windowsHide: true`. */ +function hidesWindow(expr: ts.Expression | undefined, sf: ts.SourceFile, seen = new Set()): boolean { + if (!expr) return false; + const e = unwrap(expr); + if (seen.has(e)) return false; + seen.add(e); + if (ts.isObjectLiteralExpression(e)) { + let hidden = false; + for (const prop of e.properties) { + if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === 'windowsHide') { + hidden = unwrap(prop.initializer).kind === ts.SyntaxKind.TrueKeyword; + } else if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === 'windowsHide') { + hidden = false; // not provable statically + } else if (ts.isSpreadAssignment(prop) && hidesWindow(prop.expression, sf, seen)) { + hidden = true; + } + } + return hidden; // last write wins, matching object-literal semantics + } + if (ts.isIdentifier(e)) { + const binding = resolveBinding(e.text, e); + if (!binding) return false; + if (ts.isVariableDeclaration(binding)) return hidesWindow(binding.initializer, sf, seen); + const sites = callSitesOf(binding, sf); + return sites !== null && sites.every((arg) => hidesWindow(arg, sf, seen)); + } + return false; +} + +function commandLabel(call: ts.CallExpression, sf: ts.SourceFile): string { + const first = call.arguments[0]; + if (!first) return ''; + if (ts.isStringLiteralLike(first)) return `'${first.text}'`; + return first.getText(sf); +} + +describe('child processes set windowsHide (#1092, #2094)', () => { + it('every child_process spawn under src/ sets windowsHide: true', () => { + const offenders: string[] = []; + let seen = 0; + for (const file of listSourceFiles(SRC_DIR)) { + const text = fs.readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const bindings = childProcessBindings(sf); + if (bindings.named.size === 0 && bindings.namespaces.size === 0) continue; + const rel = path.relative(SRC_DIR, file).split(path.sep).join('/'); + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const spawner = spawnerOf(node, bindings); + if (spawner) { + seen++; + const key = `${rel}:${spawner}(${commandLabel(node, sf)})`; + if (!Object.hasOwn(ALLOWED, key) && !hidesWindow(optionsArg(node, spawner), sf)) { + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + offenders.push(`${rel}:${line + 1} ${key}`); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + expect(seen).toBeGreaterThan(0); // guard against a false pass if the imports move + expect(offenders, `spawned without windowsHide:\n${offenders.join('\n')}`).toEqual([]); + }); +}); diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts index d16d70fa59..4bc89a55b1 100644 --- a/__tests__/frontload-hook.test.ts +++ b/__tests__/frontload-hook.test.ts @@ -13,7 +13,7 @@ import * as os from 'os'; import * as path from 'path'; import { spawnSync } from 'node:child_process'; import { CodeGraph } from '../src'; -import { planFrontload, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, isHostNotification, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; +import { planFrontload, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, isHostNotification, isAgentMessage, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory'; // Make the built-in exports configurable so HOME can point at a real temp // fixture without changing the process environment or the user's home files. @@ -382,6 +382,12 @@ export class OrderStateMachine { } }); + it('stays silent on a subagent hand-back envelope, even one that names indexed symbols (#2184)', () => { + const report = 'how does OrderStateMachine work? submitOrder() calls into the state machine.'; + expect(hook(report)).toContain('Structural context from CodeGraph'); + expect(hook(`\n[Subagent hand-back] ${report}\n`)).toBe(''); + }); + it('uses MEDIUM for indexed prose segments without a strong keyword or verified token', () => { for (const prompt of ['como state machine?', 'wie state machine?']) { const output = hook(prompt); @@ -496,3 +502,20 @@ describe('CodeGraph.open stays off the extraction stack', () => { expect(JSON.parse(result.stdout)).toEqual({ found: 1, loaded: [] }); }); }); + +describe('subagent hand-backs (#2184)', () => { + const handBack = '\n[Subagent hand-back] Traced how AuthService.login calls TokenStore.save and which callers are affected.\n'; + + it('skips the complete hand-back envelope', () => { + expect(isAgentMessage(handBack)).toBe(true); + expect(isAgentMessage(` \n${handBack}\n`)).toBe(true); + expect(isAgentMessage('trace AuthService login flow')).toBe(true); + }); + it('does not suppress a user question that mentions the marker', () => { + expect(isAgentMessage(`Why does ${handBack} trigger the hook?`)).toBe(false); + expect(isAgentMessage(`${handBack} Explain this.`)).toBe(false); + expect(isAgentMessage('trace AuthService')).toBe(false); + expect(isAgentMessage('trace AuthService')).toBe(false); + expect(isAgentMessage('trace AuthService login')).toBe(false); + }); +}); diff --git a/__tests__/mcp-idle-release-order.test.ts b/__tests__/mcp-idle-release-order.test.ts new file mode 100644 index 0000000000..a06736d293 --- /dev/null +++ b/__tests__/mcp-idle-release-order.test.ts @@ -0,0 +1,78 @@ +/** + * Idle release of explicit projects keeps each project's own deadline (#2087). + * + * The idle timer is armed for the oldest project a trim can release, and a + * project whose catch-up is still running is skipped. When that catch-up + * settles after a newer project armed the timer, the older project must be + * released on its own deadline, not held (with its writer lock) until the + * newer one's. Fake timers make the ordering exact. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ToolHandler, type ProjectLifecycle } from '../src/mcp/tools'; +import CodeGraph from '../src/codegraph'; + +const IDLE_MS = 3000; +// Long enough to fail validation after the project gate, so a call opens and +// gates its project without running a query. +const OVERSIZED_PATH = 'x'.repeat(100_000); + +describe('idle release order (#2087)', () => { + let workspace: string; + const prevIdle = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + const prevGate = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + + beforeEach(() => { + workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-idle-order-'))); + // Real (empty) indexes, so the roots resolve; the lifecycle below hands + // out stand-ins and never opens them. + for (const name of ['a', 'b']) CodeGraph.initSync(path.join(workspace, name)).close(); + process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = String(IDLE_MS); + process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '10'; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + if (prevIdle === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + else process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = prevIdle; + if (prevGate === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS; + else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prevGate; + fs.rmSync(workspace, { recursive: true, force: true }); + }); + + it('releases an older project on its own deadline after its catch-up settles', async () => { + const released: string[] = []; + let settleA!: () => void; + const catchUpA = new Promise((resolve) => { settleA = resolve; }); + const lifecycle: ProjectLifecycle = { + open: (root) => ({ + getProjectRoot: () => root, + reopenIfReplaced: () => false, + close: () => {}, + }) as unknown as CodeGraph, + activate: (cg) => (path.basename(cg.getProjectRoot()) === 'a' ? catchUpA : Promise.resolve()), + release: (cg) => { released.push(path.basename(cg.getProjectRoot())); }, + }; + const handler = new ToolHandler(null); + handler.setProjectLifecycle(lifecycle); + const call = async (name: string): Promise => { + const done = handler.execute('codegraph_files', { projectPath: path.join(workspace, name), path: OVERSIZED_PATH }); + await vi.advanceTimersByTimeAsync(10); // the catch-up gate's serve-anyway deadline + expect((await done).content[0]!.text).toContain('path exceeds maximum length'); + }; + + await call('a'); // t=10: A in use, its catch-up still running + await vi.advanceTimersByTimeAsync(1490); + await call('b'); // t=1510: the timer is armed for B, due at 4510 + await vi.advanceTimersByTimeAsync(990); + settleA(); // t=2510: A is due at 3010, which the 1 s floor moves to 3510 + await vi.advanceTimersByTimeAsync(1100); + expect(released).toEqual(['a']); + await vi.advanceTimersByTimeAsync(1000); + expect(released).toEqual(['a', 'b']); + await handler.closeAll(); + }); +}); diff --git a/__tests__/mcp-projectpath-lifecycle.test.ts b/__tests__/mcp-projectpath-lifecycle.test.ts index 46b4f16189..10ea19abc6 100644 --- a/__tests__/mcp-projectpath-lifecycle.test.ts +++ b/__tests__/mcp-projectpath-lifecycle.test.ts @@ -312,27 +312,6 @@ describe('MCP explicit projectPath lifecycle (#1835)', { timeout: 30_000 }, () = expect(opened[0]!.sync).toHaveBeenCalledTimes(1); }); - it('releases an idle explicit project and its writer lock, then reopens it on the next query', async () => { - const prior = process.env.CODEGRAPH_PROJECT_IDLE_RELEASE_MS; - process.env.CODEGRAPH_PROJECT_IDLE_RELEASE_MS = '300'; - try { - const lock = path.join(serviceB, '.codegraph', 'writer.pid'); - expect(await search(serviceB, 'betaOriginal')).toContain('betaOriginal'); - expect(fs.existsSync(lock)).toBe(true); - // No further calls: the idle sweep alone must hand the project back. - expect(await waitFor(async () => !fs.existsSync(lock), 10000)).toBe(true); - expect(opened[0].isWatching()).toBe(false); - - fs.writeFileSync(path.join(serviceB, 'src', 'sample.ts'), 'export function betaAfterIdle() { return 1; }\n'); - expect(await search(serviceB, 'betaAfterIdle')).toContain('betaAfterIdle'); - expect(opened).toHaveLength(2); - expect(fs.existsSync(lock)).toBe(true); - } finally { - if (prior === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_RELEASE_MS; - else process.env.CODEGRAPH_PROJECT_IDLE_RELEASE_MS = prior; - } - }); - it('keeps watching after one of two engines releases its lease', async () => { const second = new MCPEngine(); engines.push(second); @@ -463,6 +442,61 @@ describe('MCP explicit projectPath lifecycle (#1835)', { timeout: 30_000 }, () = }, 60_000); + // A daemon that answered one projectPath query for another project must not + // keep that project's writer lock until it exits: the project's own daemon + // and `codegraph index` there would stay locked out (#2087). + it('releases an idle explicit project and its writer lock, and retakes it on the next call', async () => { + const prev = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = '200'; + const lock = path.join(serviceB, '.codegraph/writer.pid'); + try { + expect(await search(serviceB, 'betaOriginal')).toContain('betaOriginal'); + expect(JSON.parse(fs.readFileSync(lock, 'utf8')).pid).toBe(process.pid); + expect(opened[0]!.isWatching()).toBe(true); + + expect(await waitFor(async () => !fs.existsSync(lock), 10000)).toBe(true); + expect(() => opened[0]!.getStats()).toThrow(); + + // Still synchronized while in use: the next call reopens and catches up. + fs.writeFileSync(path.join(serviceB, 'src/sample.ts'), 'export function afterIdleRelease() {}\n'); + expect(await search(serviceB, 'afterIdleRelease')).toContain('afterIdleRelease'); + expect(opened).toHaveLength(2); + expect(opened[1]!.isWatching()).toBe(true); + expect(JSON.parse(fs.readFileSync(lock, 'utf8')).pid).toBe(process.pid); + } finally { + if (prev === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + else process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = prev; + } + }); + + it('defers an idle release until an active catch-up finishes (#2087)', async () => { + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + onOpen = (cg) => { + const sync = cg.sync.bind(cg); + vi.spyOn(cg, 'sync').mockImplementation(async (...args) => { await held; return sync(...args); }); + }; + const prevIdle = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = '50'; + process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '10'; + const lock = path.join(serviceB, '.codegraph/writer.pid'); + try { + fs.writeFileSync(path.join(serviceB, 'src/sample.ts'), 'export function idleCatchUp() {}\n'); + await search(serviceB, 'betaOriginal'); + await new Promise((r) => setTimeout(r, 300)); + expect(() => opened[0]!.getStats()).not.toThrow(); + expect(fs.existsSync(lock)).toBe(true); + release(); + expect(await waitFor(async () => !fs.existsSync(lock), 10000)).toBe(true); + expect(names(serviceB)).toEqual(['idleCatchUp']); + expect(() => opened[0]!.getStats()).toThrow(); + } finally { + release(); + if (prevIdle === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + else process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = prevIdle; + } + }); + it('drains a tool operation before closing its cached graph', async () => { await search(serviceA, 'alphaOriginal'); const handler = engine.getToolHandler(); diff --git a/__tests__/mcp-server-discover.test.ts b/__tests__/mcp-server-discover.test.ts new file mode 100644 index 0000000000..ce6cc872b3 --- /dev/null +++ b/__tests__/mcp-server-discover.test.ts @@ -0,0 +1,134 @@ +/** + * `server/discover` probe regression tests (issue #2084). + * + * Newer MCP clients (Antigravity 2.5) send `server/discover` before the legacy + * `initialize` and wait for the answer. A legacy server is expected to reply + * `-32601 Method not found`, which sends the client on to `initialize`. The + * stdio proxy forwarded the probe to the shared daemon instead, so nothing was + * written until the daemon connected: a client that closed stdin first got no + * reply at all, and with the daemon unavailable the reply came ~6s later as a + * `-32603` the client can't read as "use initialize". + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import { once } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { recordSpawns, removeSpawnLog, settleLosingCandidates, spawnLogFor } from './daemon-candidates'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); +const DISCOVER = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server/discover' }) + '\n'; + +function readLockPid(root: string): number | null { + try { + const info = JSON.parse(fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8')); + return typeof info === 'number' ? info : typeof info.pid === 'number' ? info.pid : null; + } catch { return null; } +} + +function isAlive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +/** + * Send one `server/discover` line and resolve with everything the server wrote + * to stdout once it has exited. `closeStdin: 'at-once'` is the shape of the + * `printf … | codegraph serve --mcp` repro; `'after-reply'` is a client that + * waits for the answer (stdin is closed on the first stdout line). + */ +async function discover( + cwd: string, + env: NodeJS.ProcessEnv, + children: ChildProcessWithoutNullStreams[], + closeStdin: 'at-once' | 'after-reply' = 'at-once', +): Promise { + const recorder = recordSpawns(cwd); + const child = spawn(process.execPath, [...recorder.args, BIN, 'serve', '--mcp', '--no-watch'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...recorder.env, CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '1000', ...env }, + }) as ChildProcessWithoutNullStreams; + children.push(child); + child.on('error', () => { /* ignore */ }); + child.stdin.on('error', () => { /* ignore */ }); + let stdout = ''; + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + if (closeStdin === 'after-reply' && stdout.includes('\n')) child.stdin.end(); + }); + child.stderr.resume(); + const exited = once(child, 'exit'); + if (closeStdin === 'at-once') child.stdin.end(DISCOVER); + else child.stdin.write(DISCOVER); + await exited; + return stdout; +} + +function expectMethodNotFound(stdout: string): void { + const replies = stdout.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l)); + expect(replies).toEqual([ + { jsonrpc: '2.0', id: 1, error: expect.objectContaining({ code: -32601 }) }, + ]); +} + +describe('server/discover probe (issue #2084)', () => { + let tempDir: string; + let realRoot: string; + const children: ChildProcessWithoutNullStreams[] = []; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-discover-')); + realRoot = fs.realpathSync(tempDir); + }); + + afterEach(async () => { + await Promise.all(children.map(async (child) => { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + })); + children.length = 0; + // The proxy spawns a detached daemon candidate; wait for the losers, then + // stop the lock holder — never the vitest worker, which one test plants as + // the holder. + if (fs.existsSync(spawnLogFor(tempDir))) { + await settleLosingCandidates(tempDir, () => readLockPid(realRoot)); + const daemonPid = readLockPid(realRoot); + if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { + try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + for (let i = 0; i < 100 && isAlive(daemonPid); i++) await new Promise((r) => setTimeout(r, 50)); + } + removeSpawnLog(tempDir); + } + await fs.promises.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + }, 45_000); + + it('proxy mode answers -32601 even when the client closes stdin right after the probe', async () => { + const cg = await CodeGraph.init(tempDir); + cg.close(); + expectMethodNotFound(await discover(tempDir, {}, children)); + }, 20_000); + + it('proxy mode answers -32601, not "daemon unavailable", when the daemon cannot start', async () => { + const cg = await CodeGraph.init(tempDir); + cg.close(); + // A legacy plain-pid lock held by a live process (this worker) makes every + // daemon candidate step aside, so the proxy never gets a daemon. + fs.writeFileSync(path.join(realRoot, '.codegraph', 'daemon.pid'), `${process.pid}\n`); + expectMethodNotFound(await discover(tempDir, {}, children, 'after-reply')); + }, 20_000); + + it.each([ + ['CODEGRAPH_NO_DAEMON=1 on an indexed project', true, { CODEGRAPH_NO_DAEMON: '1' }], + ['no .codegraph/ in the working directory', false, {}], + ])('direct mode answers -32601 (%s)', async (_label, indexed, env) => { + if (indexed) { + const cg = await CodeGraph.init(tempDir); + cg.close(); + } + expectMethodNotFound(await discover(tempDir, env, children)); + }, 20_000); +}); diff --git a/__tests__/mcp-stale-refusal.test.ts b/__tests__/mcp-stale-refusal.test.ts index be04dcf821..f0eb61f0e9 100644 --- a/__tests__/mcp-stale-refusal.test.ts +++ b/__tests__/mcp-stale-refusal.test.ts @@ -149,6 +149,7 @@ describe('a degraded index names changed files and still answers (#1959)', () => expect(raw._cgAnswerFiles!.length).toBeGreaterThan(200); const result = await handler.execute('codegraph_impact', { symbol: 'alphaOnly' }); expect(result.isError).toBeFalsy(); + expect(result).not.toHaveProperty('structuredContent'); const text = result.content[0].text; expect(text).toContain('validation budget'); expect(text).toContain('alphaOnly'); diff --git a/__tests__/mcp-status-freshness.test.ts b/__tests__/mcp-status-freshness.test.ts index c2a5d7c68a..923f77ae3f 100644 --- a/__tests__/mcp-status-freshness.test.ts +++ b/__tests__/mcp-status-freshness.test.ts @@ -42,7 +42,7 @@ describe('MCP status freshness (#1959)', () => { fs.writeFileSync(path.join(root, 'add.ts'), 'export const added = 1;\n'); const result = await handler.execute('codegraph_status', {}); - // Claude Code would show structuredContent in place of this text. + // Claude Code would show structuredContent in place of this text (#2088). expect(result).not.toHaveProperty('structuredContent'); const changed = result.content[0].text; expect(changed).toContain('**Changes since index:** 1 added, 1 modified, 1 removed'); diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index 19f6bb75b9..5550509684 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -9,7 +9,7 @@ CodeGraph runs as a [Model Context Protocol](https://modelcontextprotocol.io/) s codegraph serve --mcp ``` -The tools below are listed in every workspace. Where the project has no `.codegraph/` index, a call returns guidance (for example, to pass `projectPath` for a sub-project that is indexed) instead of an error, and indexing stays your decision. A project opened by `projectPath` is watched and kept in sync while the session uses it, and released after 10 minutes without a query; set `CODEGRAPH_PROJECT_IDLE_RELEASE_MS` to change that (`0` keeps it open until the server exits). +The tools below are listed in every workspace. Where the project has no `.codegraph/` index, a call returns guidance (for example, to pass `projectPath` for a sub-project that is indexed) instead of an error, and indexing stays your decision. A project opened by `projectPath` is watched and kept in sync while the session uses it, and released after 10 minutes without a query; set `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS` to change that (`0` keeps it open until the server exits). ## Two tools by default: `codegraph_explore` and `codegraph_sessions` diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index f563746d88..dfa733c29c 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -53,7 +53,7 @@ try { import { Command } from 'commander'; import * as path from 'path'; import * as fs from 'fs'; -import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, isHostNotification, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; +import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, hasStructuralKeyword, isHostNotification, isAgentMessage, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory'; import { extractProseCandidates } from '../search/identifier-segments'; import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree'; import { createShimmerProgress } from '../ui/shimmer-progress'; @@ -1603,7 +1603,8 @@ program // Keywords fire on their own; a token or prose word is only a CANDIDATE // verified against the graph below, so a tech brand ("JavaScript") that // merely looks like code doesn't inject spurious context. - if (isHostNotification(prompt)) { gate('noop-notification'); return; } + // Host notifications and subagent hand-backs (#2184) are not user prompts. + if (isHostNotification(prompt) || isAgentMessage(prompt)) { gate('noop-notification'); return; } const keyworded = hasStructuralKeyword(prompt); // "review my changes", `main..HEAD`: explore answers these from the diff, // so a confirmed change question gets the full injection like a keyword. diff --git a/src/directory.ts b/src/directory.ts index 7ea713f3b4..056e29a9f2 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -1078,3 +1078,13 @@ export function validateDirectory(projectRoot: string): { errors, }; } + +/** + * Claude Code hands a subagent's report back to the parent session as a + * `…` prompt, which UserPromptSubmit + * hooks also receive (#2184). Only a prompt that is entirely that envelope is + * skipped; a user question that mentions the marker is still a prompt. + */ +export function isAgentMessage(prompt: string): boolean { + return /^\s*]*)?>[\s\S]*<\/agent-message>\s*$/.test(prompt); +} diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 6afeaab1c1..4c2f0278af 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -398,7 +398,7 @@ function readGitExcludeExtraPatterns(rootDir: string): string { const configured = execFileSync( 'git', ['-C', rootDir, 'config', '--get', 'core.excludesFile'], - { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] }, + { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }, ).trim(); if (configured) { const abs = expandUserPath(configured); @@ -429,6 +429,7 @@ function listGitIgnoredDirectories(rootDir: string): string[] { timeout: 60_000, maxBuffer: 50 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }, ); const dirs: string[] = []; diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 49046de2ff..61722623ae 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -30,6 +30,7 @@ import { CodeGraphPackageVersion } from './version'; import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session'; import { SERVER_INSTRUCTIONS } from './server-instructions'; import { getStaticTools } from './tools'; +import { ErrorCodes } from './transport'; import { ExploreSessionState } from './explore-session-state'; import { getTelemetry, ClientInfo } from '../telemetry'; import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog'; @@ -333,11 +334,13 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< } else if (msg.method === 'prompts/list') { writeClient({ jsonrpc: '2.0', id: msg.id, result: { prompts: [] } }); } else if (msg.method === 'server/discover' && msg.id !== undefined) { - // A dual-era client (Claude Code) probes this before `initialize` and - // holds the handshake until it hears back. Method-not-found is the - // legacy-server answer that sends it to `initialize`; forwarded, the - // probe waited on the daemon connection, about 6 s when it was down. - writeClient({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found: server/discover' } }); + // Dual-era clients (Claude Code, Antigravity 2.5) probe this before + // `initialize` and wait for the answer. Method-not-found is the + // legacy-server answer that sends them on to `initialize`. Answer + // locally: forwarded, the probe waited on the daemon connection (about + // 6 s when it was down) and was lost if the client closed stdin first. + // (#2084) + writeClient({ jsonrpc: '2.0', id: msg.id, error: { code: ErrorCodes.MethodNotFound, message: 'Method not found: server/discover' } }); } else { routeToDaemon(line); } diff --git a/src/mcp/refresh-launcher.ts b/src/mcp/refresh-launcher.ts index 58e6c783bc..24a2bab071 100644 --- a/src/mcp/refresh-launcher.ts +++ b/src/mcp/refresh-launcher.ts @@ -69,6 +69,7 @@ class Backend { ], { stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, env: { ...process.env, [HOST_PPID_ENV]: String(parseHostPpid(process.env[HOST_PPID_ENV]) ?? EARLY_PPID), diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index e0cb5a6ddf..e3d1d5918b 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2137,20 +2137,20 @@ function canonicalPath(p: string): string { export const MAX_CACHED_PROJECTS = 8; /** - * How long an explicit-`projectPath` project may go unqueried before the - * handler releases it. A cached project can hold that project's writer lock - * (#1835), so a long-lived daemon that was asked about another checkout once - * would otherwise keep that checkout's own daemon from starting for as long as - * it lives. Releasing costs the next query a reopen + catch-up. - * `CODEGRAPH_PROJECT_IDLE_RELEASE_MS` overrides it; `0` keeps projects until - * LRU eviction or shutdown. + * How long an explicit-`projectPath` project may go unused before the handler + * releases it (#2087). Releasing frees its SQLite handle, its watcher and the + * writer lock the engine may hold on that project — which otherwise stays held + * for the whole life of this daemon, locking the project's own daemon and + * `codegraph index` out. The next call reopens it and catches up. + * `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS` overrides it; `0` never releases. */ -export const DEFAULT_PROJECT_IDLE_RELEASE_MS = 600_000; - -export function resolveProjectIdleReleaseMs(raw = process.env.CODEGRAPH_PROJECT_IDLE_RELEASE_MS): number { - if (raw === undefined || raw === '') return DEFAULT_PROJECT_IDLE_RELEASE_MS; - const parsed = Number(raw); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_PROJECT_IDLE_RELEASE_MS; +const DEFAULT_PROJECT_IDLE_TIMEOUT_MS = 600_000; +function resolveProjectIdleTimeoutMs(): number { + const raw = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; + if (raw === undefined || raw === '') return DEFAULT_PROJECT_IDLE_TIMEOUT_MS; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return DEFAULT_PROJECT_IDLE_TIMEOUT_MS; + return Math.floor(n); } /** @@ -2178,10 +2178,10 @@ export class ToolHandler { // CANONICAL (realpath) index root. Map insertion order doubles as LRU order: // a hit re-inserts, and `MAX_CACHED_PROJECTS` bounds the size (#1835). private projectCache: Map = new Map(); - // When each cached root was last queried; idle ones are released (see - // DEFAULT_PROJECT_IDLE_RELEASE_MS). The default project is never cached. - private projectLastUsed: Map = new Map(); - private projectIdleSweep: NodeJS.Timeout | null = null; + // When each cached root was last handed to a call, and the one timer that + // releases the oldest once it has been idle long enough (#2087). + private projectUsedAt: Map = new Map(); + private idleReleaseTimer: NodeJS.Timeout | null = null; // Engine hook that watches + catches up an explicit project (null for the // CLI and worker-thread handlers, which never own a watcher). private projectLifecycle: ProjectLifecycle | null = null; @@ -2552,7 +2552,7 @@ export class ToolHandler { // Refresh LRU position. this.projectCache.delete(canonicalRoot); this.projectCache.set(canonicalRoot, cached); - this.projectLastUsed.set(canonicalRoot, Date.now()); + this.projectUsedAt.set(canonicalRoot, Date.now()); return this.freshen(cached); } @@ -2562,7 +2562,7 @@ export class ToolHandler { if (isSameIndexRoot(root, resolvedRoot)) { this.projectCache.delete(root); this.projectCache.set(root, open); - this.projectLastUsed.set(root, Date.now()); + this.projectUsedAt.set(root, Date.now()); return this.freshen(open); } } @@ -2570,8 +2570,7 @@ export class ToolHandler { const open = () => loadCodeGraph().openSync(canonicalRoot); const cg = this.projectLifecycle?.open(canonicalRoot, open) ?? open(); this.projectCache.set(canonicalRoot, cg); - this.projectLastUsed.set(canonicalRoot, Date.now()); - this.armProjectIdleSweep(); + this.projectUsedAt.set(canonicalRoot, Date.now()); this.trimProjects(); return cg; } @@ -2591,26 +2590,21 @@ export class ToolHandler { await this.awaitCatchUpGate(gate); } - /** Release cached projects idle past the TTL even when no tool call arrives. */ - private armProjectIdleSweep(): void { - const idleMs = resolveProjectIdleReleaseMs(); - if (this.projectIdleSweep || idleMs === 0) return; - this.projectIdleSweep = setInterval(() => this.trimProjects(), Math.min(idleMs, 60_000)); - this.projectIdleSweep.unref(); - } - - /** Never evict a graph while a tool call or its timed-out reconcile uses it. */ + /** + * Never evict a graph while a tool call or its timed-out reconcile uses it. + * Evicts over the LRU bound, on close, and once idle past the timeout + * (#2087). The cache is in last-use order, so idle entries lead it. + */ private trimProjects(): void { if (this.activeCalls > 0) return; - const idleMs = resolveProjectIdleReleaseMs(); + const idleMs = resolveProjectIdleTimeoutMs(); const now = Date.now(); - // Map order is LRU order, so the idle entries come first. for (const [root, cg] of this.projectCache) { - const idle = idleMs > 0 && now - (this.projectLastUsed.get(root) ?? now) >= idleMs; + const idle = idleMs > 0 && now - (this.projectUsedAt.get(root) ?? now) >= idleMs; if (!this.closing && this.projectCache.size <= MAX_CACHED_PROJECTS && !idle) break; if (this.projectGates.has(cg)) continue; this.projectCache.delete(root); - this.projectLastUsed.delete(root); + this.projectUsedAt.delete(root); if (this.projectLifecycle) { this.pendingCloses++; void Promise.resolve(this.projectLifecycle.release(cg)).finally(() => { @@ -2619,13 +2613,33 @@ export class ToolHandler { }); } else cg.close(); } - if (this.projectCache.size === 0 && this.projectIdleSweep) { - clearInterval(this.projectIdleSweep); - this.projectIdleSweep = null; - } if (this.closing && this.projectCache.size === 0 && this.pendingCloses === 0) { for (const resolve of this.closeWaiters.splice(0)) resolve(); } + this.scheduleIdleRelease(idleMs); + } + + /** + * Arm one unref'd timer for the oldest project a trim could release. A + * project whose catch-up is still running is trimmed when that settles; the + * 1s floor keeps a project a trim must skip from re-arming in a tight loop. + * Every trim re-arms, so an older project whose catch-up settles after a + * newer one armed the timer keeps its own deadline. + */ + private scheduleIdleRelease(idleMs: number): void { + if (this.idleReleaseTimer) clearTimeout(this.idleReleaseTimer); + this.idleReleaseTimer = null; + if (this.closing || idleMs <= 0) return; + for (const [root, cg] of this.projectCache) { + if (this.projectGates.has(cg)) continue; + const due = (this.projectUsedAt.get(root) ?? Date.now()) + idleMs - Date.now(); + this.idleReleaseTimer = setTimeout(() => { + this.idleReleaseTimer = null; + this.trimProjects(); + }, Math.min(Math.max(due, 1000), 0x7fffffff)); // setTimeout's 32-bit cap + this.idleReleaseTimer.unref(); + return; + } } /** @@ -2660,6 +2674,8 @@ export class ToolHandler { closeAll(): Promise { this.closing = true; this.worktreeMismatchCache.clear(); + if (this.idleReleaseTimer) clearTimeout(this.idleReleaseTimer); + this.idleReleaseTimer = null; this.trimProjects(); if (this.projectCache.size === 0 && this.activeCalls === 0 && this.pendingCloses === 0) return Promise.resolve(); return new Promise((resolve) => this.closeWaiters.push(resolve)); @@ -9002,6 +9018,7 @@ export class ToolHandler { } } + // Text only, for the same reason as the stale-answer path (#2088). return this.textResult(lines.join('\n')); } diff --git a/src/sessions/git-log.ts b/src/sessions/git-log.ts index d9e8e1ff55..0a4f6f5b17 100644 --- a/src/sessions/git-log.ts +++ b/src/sessions/git-log.ts @@ -18,6 +18,7 @@ function git(root: string, args: string[]): string | null { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024, + windowsHide: true, }); } catch { return null; diff --git a/src/sessions/index.ts b/src/sessions/index.ts index c051fb1d0a..847ceff7fe 100644 --- a/src/sessions/index.ts +++ b/src/sessions/index.ts @@ -431,6 +431,7 @@ export function projectRemotes(projectRoot: string): string[] { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }); return [...new Set(out.split('\n').map((l) => l.split(' ')[1]).filter(Boolean).map((u) => normalizeRemote(u!)))]; } catch { diff --git a/src/sessions/project-roots.ts b/src/sessions/project-roots.ts index 53b3fe033e..d6e1957d85 100644 --- a/src/sessions/project-roots.ts +++ b/src/sessions/project-roots.ts @@ -39,6 +39,7 @@ export function projectWorktreeRoots(projectRoot: string): string[] { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }); for (const line of out.split('\n')) { if (!line.startsWith('worktree ')) continue; diff --git a/src/ui-server/api/trails.ts b/src/ui-server/api/trails.ts index 5babe4c14e..f9f4d74f66 100644 --- a/src/ui-server/api/trails.ts +++ b/src/ui-server/api/trails.ts @@ -458,6 +458,7 @@ function gitUserName(projectRoot: string): string | null { encoding: 'utf-8', timeout: 2_000, stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }); const name = out.trim(); return name === '' ? null : name.slice(0, 120); diff --git a/src/ui-server/highlight/bounded-tokenize.ts b/src/ui-server/highlight/bounded-tokenize.ts index 061654b6f2..1e1493d801 100644 --- a/src/ui-server/highlight/bounded-tokenize.ts +++ b/src/ui-server/highlight/bounded-tokenize.ts @@ -33,7 +33,9 @@ function retire(outcome: BoundedTokenize): Promise { function ensureChild(file: string): ChildProcess { if (child) return child; - const c = fork(file, [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], execArgv: [] }); + // fork() passes its options on to spawn(), which reads windowsHide; ForkOptions + // just doesn't declare it, hence the spread. + const c = fork(file, [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'], execArgv: [], ...{ windowsHide: true } }); c.unref(); c.channel?.unref(); c.on('message', (msg: { id: number; result: TokenizeResult | null }) => {