Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 46 additions & 11 deletions integrations/opencode/codesearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -1331,6 +1343,10 @@ export default {
const seenMessages = new Set<string>()
const seenSessions = new Set<string>()
const nudged = new Set<string>()
/** 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<string, string[]>()

function markSeen(set: Set<string>, key: string): boolean {
if (set.has(key)) return false
Expand All @@ -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)
Expand Down Expand Up @@ -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.`)
}
Expand Down Expand Up @@ -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: "<concept>" } (add mode: "literal", regex: true for exact syntax)\n` +
` - find { project: "${scope.project}", symbol: "<name>", kind: "definition" | "usages" | "imports" | "dependents" }\n` +
` - find_impact { project: "${scope.project}", symbol_name: "<name>" } (C#/TypeScript call sites)\n` +
` - explore { project: "${scope.project}", target: "<file>", kind: "outline" }\n` +
` - get_chunk { project: "${scope.project}", chunk_id: <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: "<concept>" } (add mode: "literal", regex: true for exact syntax)`,
` - find { project: "${scope.project}", symbol: "<name>", kind: "definition" | "usages" | "imports" | "dependents" }`,
` - find_impact { project: "${scope.project}", symbol_name: "<name>" } (C#/TypeScript call sites)`,
` - explore { project: "${scope.project}", target: "<file>", kind: "outline" }`,
` - get_chunk { project: "${scope.project}", chunk_id: <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<void> {
Expand All @@ -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}`)
}
Expand Down
30 changes: 30 additions & 0 deletions integrations/opencode/test/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,17 @@ async function main(): Promise<void> {
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",
Expand Down Expand Up @@ -425,6 +436,25 @@ async function main(): Promise<void> {
}
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" })
Expand Down
19 changes: 18 additions & 1 deletion integrations/opencode/test/unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>

Expand Down Expand Up @@ -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
* ------------------------------------------------------------------ */
Expand Down Expand Up @@ -223,6 +239,7 @@ async function testHealthRunner(): Promise<void> {

async function main(): Promise<void> {
testStripJsonc()
testAstGrepToolNames()
await testHealthRunner()
console.log("unit: OK")
}
Expand Down
Loading