diff --git a/CHANGELOG.md b/CHANGELOG.md index 342623fe..4b2eb455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ finalized in place with a date — no renaming/migration step needed. - **Worktree groups: registering one member of a git-worktree family makes the whole family searchable as one unit.** Coders work in git worktrees of the same repo, and until now a group had to enumerate each worktree alias by hand — miss one and its code is invisible to every group search. Each registered repo now records its git identity (`git rev-parse --git-common-dir`, with a filesystem-only fallback reading `.git`/`gitdir:`/`commondir` when git cannot run), captured at registration and lazily backfilled for pre-existing entries; the identity is persisted, so a deleted main tree keeps its family. Group resolution expands to every registered repo sharing a member's identity — default on, per-group opt-out via `groups_meta.expand_worktrees` / `codesearch groups add --no-worktrees`, and `@peer` refs plus the virtual `all` group are untouched. Search results collapse exact duplicates within a family (key: family + relative path + content hash) across all three multi-repo paths — semantic, hybrid, literal — keeping the best score, recording alternate origins in `also_in`, and backfilling the freed slots to the requested limit, so a family of four worktrees no longer floods the top-N with the same hit. Provenance is annotation-only and flat where machine-readable: local group hits carry `source` (the origin alias), collapsed hits list `also_in`, status reports `worktree_of`, scope errors list `worktree_families`, and the TUI repo table plus `codesearch groups list` render `main (+ N worktrees)` / `wt (worktree of main)`. +### Changed + +- **OpenCode plugin: syntax-shaped queries are routed to ast-grep tools, but only when the host exposes them.** codesearch matches text, not code structure, so the injected guidance now adds one routing line (and the block-mode denial one alternative line) pointing at ast-grep tools such as `ast_grep_search`/`ast_grep_edit` — gated per request on the assembled tool map, so environments without an ast-grep integration see unchanged guidance and no one is pointed at tools they do not have. Unit and smoke coverage pin both the mention and its absence. + ### Fixed - **The serve hub no longer leaks Windows kernel mutex handles until the OS handle table saturates and the hub dies.** Two stacked causes, both measured live on a hub that crashed with `os error 1450` (`Insufficient system resources`) and tokio `failed to spawn thread` after holding the process-limit 2²⁴ ≈ 16.7M handles. (1) LMDB's Windows port (lmdb-master-sys 0.2.6, heed 0.23's backend) leaks two anonymous kernel mutex handles per `mdb_env_open`: the env's reader/writer-lock mutexes are created unconditionally but `mdb_env_close` destroys only the resize mutex. (2) The shared SCIP env cache held only `Weak` references by design, so the environment died between operations and every later query re-opened it — ~250 opens/sec on a busy hub, ≈1.7M opens ≈ 3.4M leaked handles, saturating the handle table in about a day. The cache now holds strong references: the SCIP env lives for the process lifetime and `mdb_env_open` runs once per directory (the leak drops to ~2 handles per rebuild, the env-open count itself). A new `close_shared_envs_under` releases the cached env before every in-process DB-directory delete (`index rm`/`clear`/force rebuild in the CLI; `remove_repo`, corruption format-recovery and the orphan-cleanup backstop in serve — the cache-only env would otherwise lock the directory forever), and `open_holders_under` no longer counts a cache-only env as a holder, so idle eviction and the held-in-process status keep their old semantics. The timing after the heed 0.23 bump was churn volume, not new code: lmdb-master-sys 0.2.6 predates the bump and is still the latest release; an upstream fix remains the endgame for the residual per-open leak. diff --git a/integrations/opencode/codesearch.ts b/integrations/opencode/codesearch.ts index 36e6395c..b4f6afbb 100644 --- a/integrations/opencode/codesearch.ts +++ b/integrations/opencode/codesearch.ts @@ -1148,6 +1148,18 @@ function formatSearchHits(result: Loose | undefined, limit: number): string[] { return lines } +/** + * Names of tools in a request's tool map that look like an ast-grep + * integration (`ast_grep_search`, `ast-grep_edit`, ...). Every ast-grep + * mention in this plugin is gated on this: codesearch users are not expected + * to have ast-grep installed, so guidance must never name tools the host does + * not expose. Unknown or absent maps stay silent. + */ +export function astGrepToolNames(tools: unknown): string[] { + if (!isPlainObject(tools)) return [] + return Object.keys(tools as Loose).filter((name) => /ast[-_]grep/i.test(name)) +} + /* ------------------------------------------------------------------ * * Plugin * ------------------------------------------------------------------ */ @@ -1331,6 +1343,10 @@ export default { const seenMessages = new Set() const seenSessions = new Set() const nudged = new Set() + /** Sessions whose request tool map exposed ast-grep tools, set by the + * context hook and read by block mode so its denial only ever names + * callable tools. An absent entry means "do not mention ast-grep". */ + const astGrepBySession = new Map() function markSeen(set: Set, key: string): boolean { if (set.has(key)) return false @@ -1354,6 +1370,13 @@ export default { const system = Array.isArray(event?.system) ? (event.system as SystemPart[]) : undefined if (!system) return + // Tool-map snapshot: this is the only place the assembled tool map is + // visible, and every ast-grep mention is gated on it so environments + // without an ast-grep integration see unchanged guidance. + const astGrep = astGrepToolNames(event?.tools) + astGrepBySession.set(sessionID, astGrep) + if (astGrepBySession.size > MAX_SEEN) astGrepBySession.clear() + // 1. Opt-in auto-recall: inject index context for the user message. if (settings.recall.enabled && scope.project && isLikelyCodeQuestion(userMessage.text)) { await injectRecall(system, sessionID, userMessage.text, scope) @@ -1382,6 +1405,11 @@ export default { if (health.state === "down" && !pruneActive) { extra.push(`- The codesearch serve hub is unreachable right now; grep/glob is an acceptable fallback until it recovers.`) } + if (astGrep.length > 0) { + extra.push( + `- For syntax-shaped queries and multi-file mechanical rewrites, prefer the ast-grep tools (${astGrep.join(" / ")}); codesearch and grep match text, not structure.`, + ) + } if (freshUnindexedWarning(scope)) { extra.push(`- The registered index for "${scope.project}" is empty or was built with an unknown model; searches will miss until \`codesearch index\` refreshes it.`) } @@ -1447,17 +1475,23 @@ export default { /* ---------------- tool hooks (guards, rescue, nudge) ---------------- */ - function blockMessage(tool: string, scope: ScopeInfo): string { - return ( - `codesearch: ${tool} is disabled here — this repository is indexed as project "${scope.project}".\n` + - `Use the codesearch MCP tools instead:\n` + - ` - search { project: "${scope.project}", query: "" } (add mode: "literal", regex: true for exact syntax)\n` + - ` - find { project: "${scope.project}", symbol: "", kind: "definition" | "usages" | "imports" | "dependents" }\n` + - ` - find_impact { project: "${scope.project}", symbol_name: "" } (C#/TypeScript call sites)\n` + - ` - explore { project: "${scope.project}", target: "", kind: "outline" }\n` + - ` - get_chunk { project: "${scope.project}", chunk_id: }\n` + - `If codesearch is genuinely unavailable, set "guards": { "mode": "off" } in ~/.config/opencode/codesearch.json (or CODESEARCH_PLUGIN_GUARDS=off) and retry.` + function blockMessage(tool: string, scope: ScopeInfo, astGrep: string[] = []): string { + const lines = [ + `codesearch: ${tool} is disabled here — this repository is indexed as project "${scope.project}".`, + `Use the codesearch MCP tools instead:`, + ` - search { project: "${scope.project}", query: "" } (add mode: "literal", regex: true for exact syntax)`, + ` - find { project: "${scope.project}", symbol: "", kind: "definition" | "usages" | "imports" | "dependents" }`, + ` - find_impact { project: "${scope.project}", symbol_name: "" } (C#/TypeScript call sites)`, + ` - explore { project: "${scope.project}", target: "", kind: "outline" }`, + ` - get_chunk { project: "${scope.project}", chunk_id: }`, + ] + if (astGrep.length > 0) { + lines.push(` - ${astGrep.join(" / ")} for syntax-shaped queries and multi-file rewrites (structure, not text)`) + } + lines.push( + `If codesearch is genuinely unavailable, set "guards": { "mode": "off" } in ~/.config/opencode/codesearch.json (or CODESEARCH_PLUGIN_GUARDS=off) and retry.`, ) + return lines.join("\n") } async function onToolBefore(event: Loose): Promise { @@ -1469,7 +1503,8 @@ export default { const alias = coveredAlias() if (!alias) return if (health.state !== "ok") return // fail open unless the hub is confirmed up - blocked = new Error(blockMessage(tool, resolveScope())) + const astGrep = astGrepBySession.get(String(event?.sessionID ?? "")) ?? [] + blocked = new Error(blockMessage(tool, resolveScope(), astGrep)) } catch (err) { if (settings.debug) warn(`execute.before error: ${err instanceof Error ? err.message : err}`) } diff --git a/integrations/opencode/test/smoke.ts b/integrations/opencode/test/smoke.ts index 1e555b0a..97650441 100644 --- a/integrations/opencode/test/smoke.ts +++ b/integrations/opencode/test/smoke.ts @@ -321,6 +321,17 @@ async function main(): Promise { await one.app.hooks.context[0](duplicate) assert.equal(duplicate.system.length, 0, "the same user message never injects twice") + // ast-grep routing is gated on the request's tool map: absent tools stay + // silent, exposed tools get one routing line. + assert.doesNotMatch(injected, /ast[-_]grep/, "no ast-grep mention without ast-grep tools") + const withAstGrep = userMessage("s1-ast", "ag1", "Migrate the call sites") + withAstGrep.tools.ast_grep_search = {} + withAstGrep.tools.ast_grep_edit = {} + await one.app.hooks.context[0](withAstGrep) + const astGrepGuidance = withAstGrep.system.map((p: Loose) => p.text).join("\n") + assert.match(astGrepGuidance, /ast_grep_search/, "guidance names ast-grep tools when the host exposes them") + assert.match(astGrepGuidance, /ast_grep_edit/, "guidance names the rewrite tool too") + // Zero-hit rescue turns an empty grep into codesearch hits. const rescueEvent: Loose = { tool: "grep", @@ -425,6 +436,25 @@ async function main(): Promise { } assert.match(threw, /codesearch/, "block mode denies grep once the hub is reachable") assert.match(threw, /project "my-service"/, "denial names the project scope to use") + assert.doesNotMatch(threw, /ast[-_]grep/, "denial omits ast-grep when the session never exposed it") + + // The same denial names ast-grep once the session's tool map has it. + const astBlocked = await setupPlugin("/work/my-service") + const astContext = userMessage("s6", "agb1", "Migrate the call sites") + astContext.tools.ast_grep_search = {} + astContext.tools.ast_grep_edit = {} + await astBlocked.app.hooks.context[0](astContext) + let astThrew = "" + for (let i = 0; i < 30 && !astThrew; i++) { + try { + await astBlocked.app.toolHooks["execute.before"][0]({ tool: "grep", input: { pattern: "x" }, sessionID: "s6" }) + } catch (err) { + astThrew = String(err) + } + if (!astThrew) await new Promise((r) => setTimeout(r, 100)) + } + assert.match(astThrew, /ast_grep_search/, "denial names ast-grep tools when the host exposes them") + await astBlocked.cleanup() const uncovered = await setupPlugin("/work/unregistered") await uncovered.app.toolHooks["execute.before"][0]({ tool: "grep", input: {}, sessionID: "s4" }) diff --git a/integrations/opencode/test/unit.ts b/integrations/opencode/test/unit.ts index 814335c0..417b1497 100644 --- a/integrations/opencode/test/unit.ts +++ b/integrations/opencode/test/unit.ts @@ -12,7 +12,7 @@ import assert from "node:assert/strict" import http from "node:http" import type { AddressInfo } from "node:net" -import { HealthRunner, stripJsonc } from "../codesearch.ts" +import { HealthRunner, astGrepToolNames, stripJsonc } from "../codesearch.ts" type Loose = Record @@ -61,6 +61,22 @@ function testStripJsonc(): void { assert.deepEqual(JSON.parse(stripJsonc('{\r\n // note\r\n "a": 1\r\n}\r\n')), { a: 1 }) } +/* ------------------------------------------------------------------ * + * astGrepToolNames + * ------------------------------------------------------------------ */ + +function testAstGrepToolNames(): void { + assert.deepEqual(astGrepToolNames(undefined), [], "no tool map is silent") + assert.deepEqual(astGrepToolNames(null), [], "null tool map is silent") + assert.deepEqual(astGrepToolNames("ast_grep_search"), [], "non-object maps are ignored") + assert.deepEqual(astGrepToolNames({ grep: {}, glob: {}, read: {} }), [], "non-ast-grep tools are ignored") + assert.deepEqual( + astGrepToolNames({ read: {}, ast_grep_search: {}, ast_grep_edit: {}, "ast-grep_search": {} }), + ["ast_grep_search", "ast_grep_edit", "ast-grep_search"], + "ast-grep-shaped names are returned in tool-map order", + ) +} + /* ------------------------------------------------------------------ * * HealthRunner * ------------------------------------------------------------------ */ @@ -223,6 +239,7 @@ async function testHealthRunner(): Promise { async function main(): Promise { testStripJsonc() + testAstGrepToolNames() await testHealthRunner() console.log("unit: OK") }