فهرست منبع

refactor(core): unify filesystem search service (#31566)

Dax 2 ماه پیش
والد
کامیت
a0409e64d8
57فایلهای تغییر یافته به همراه962 افزوده شده و 2852 حذف شده
  1. 2 0
      bun.lock
  2. 1 0
      package.json
  3. 35 94
      packages/core/src/filesystem.ts
  4. 0 487
      packages/core/src/filesystem/ripgrep.ts
  5. 23 0
      packages/core/src/filesystem/schema.ts
  6. 206 543
      packages/core/src/filesystem/search.ts
  7. 5 18
      packages/core/src/location-layer.ts
  8. 0 219
      packages/core/src/location-search.ts
  9. 2 3
      packages/core/src/public/opencode.ts
  10. 120 28
      packages/core/src/ripgrep.ts
  11. 124 0
      packages/core/src/ripgrep/binary.ts
  12. 39 23
      packages/core/src/tool/glob.ts
  13. 67 40
      packages/core/src/tool/grep.ts
  14. 1 2
      packages/core/src/tool/read-filesystem.ts
  15. 0 231
      packages/core/test/filesystem/ripgrep.test.ts
  16. 33 165
      packages/core/test/filesystem/search.test.ts
  17. 22 110
      packages/core/test/location-filesystem.test.ts
  18. 1 2
      packages/core/test/location-layer.test.ts
  19. 0 260
      packages/core/test/location-search.test.ts
  20. 39 0
      packages/core/test/ripgrep.test.ts
  21. 0 153
      packages/core/test/tool-glob.test.ts
  22. 0 217
      packages/core/test/tool-grep.test.ts
  23. 1 0
      packages/opencode/package.json
  24. 11 30
      packages/opencode/script/bench-search.ts
  25. 3 1
      packages/opencode/script/build.ts
  26. 5 18
      packages/opencode/src/cli/cmd/debug/file.ts
  27. 20 40
      packages/opencode/src/cli/cmd/debug/ripgrep.ts
  28. 6 5
      packages/opencode/src/effect/app-runtime.ts
  29. 0 14
      packages/opencode/src/project/bootstrap.ts
  30. 15 2
      packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
  31. 21 14
      packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts
  32. 2 2
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  33. 4 3
      packages/opencode/src/tool/external-directory.ts
  34. 10 15
      packages/opencode/src/tool/glob.ts
  35. 15 40
      packages/opencode/src/tool/grep.ts
  36. 1 4
      packages/opencode/src/tool/read.ts
  37. 3 30
      packages/opencode/src/tool/registry.ts
  38. 11 12
      packages/opencode/src/tool/skill.ts
  39. 2 0
      packages/opencode/test/control-plane/workspace.test.ts
  40. 4 1
      packages/opencode/test/plugin/workspace-adapter.test.ts
  41. 2 1
      packages/opencode/test/server/httpapi-instance-context.test.ts
  42. 2 1
      packages/opencode/test/server/httpapi-promptasync-context.test.ts
  43. 2 1
      packages/opencode/test/server/httpapi-session.test.ts
  44. 2 1
      packages/opencode/test/server/httpapi-workspace-routing.test.ts
  45. 2 1
      packages/opencode/test/server/httpapi-workspace.test.ts
  46. 2 2
      packages/opencode/test/session/prompt.test.ts
  47. 2 2
      packages/opencode/test/session/snapshot-tool-race.test.ts
  48. 4 1
      packages/opencode/test/session/structured-output-integration.test.ts
  49. 2 2
      packages/opencode/test/tool/glob.test.ts
  50. 21 2
      packages/opencode/test/tool/grep.test.ts
  51. 2 2
      packages/opencode/test/tool/read.test.ts
  52. 2 2
      packages/opencode/test/tool/registry.test.ts
  53. 2 1
      packages/opencode/test/tool/skill.test.ts
  54. 2 1
      packages/opencode/test/tool/task.test.ts
  55. 27 6
      packages/server/src/handlers/fs.ts
  56. 1 0
      packages/tui/src/component/prompt/autocomplete.tsx
  57. 31 0
      patches/@ff-labs%2Ffff-bun@0.9.3.patch

+ 2 - 0
bun.lock

@@ -525,6 +525,7 @@
         "@clack/prompts": "1.0.0-alpha.1",
         "@clack/prompts": "1.0.0-alpha.1",
         "@effect/opentelemetry": "catalog:",
         "@effect/opentelemetry": "catalog:",
         "@effect/platform-node": "catalog:",
         "@effect/platform-node": "catalog:",
+        "@ff-labs/fff-bun": "0.9.3",
         "@gitlab/opencode-gitlab-auth": "1.3.3",
         "@gitlab/opencode-gitlab-auth": "1.3.3",
         "@modelcontextprotocol/sdk": "1.29.0",
         "@modelcontextprotocol/sdk": "1.29.0",
         "@octokit/graphql": "9.0.2",
         "@octokit/graphql": "9.0.2",
@@ -914,6 +915,7 @@
   "patchedDependencies": {
   "patchedDependencies": {
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
     "virtua@0.49.1": "patches/virtua@0.49.1.patch",
     "virtua@0.49.1": "patches/virtua@0.49.1.patch",
+    "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
     "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
     "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
     "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",

+ 1 - 0
package.json

@@ -140,6 +140,7 @@
     "@types/node": "catalog:"
     "@types/node": "catalog:"
   },
   },
   "patchedDependencies": {
   "patchedDependencies": {
+    "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
     "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
     "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
     "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",

+ 35 - 94
packages/core/src/filesystem.ts

@@ -6,8 +6,10 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
 import { EventV2 } from "./event"
 import { EventV2 } from "./event"
 import { FSUtil } from "./fs-util"
 import { FSUtil } from "./fs-util"
 import { Location } from "./location"
 import { Location } from "./location"
-import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
-import { Search } from "./filesystem/search"
+import { PositiveInt, RelativePath } from "./schema"
+import { FileSystemSearch } from "./filesystem/search"
+import { Entry, Match } from "./filesystem/schema"
+export { Entry, Match, Submatch } from "./filesystem/schema"
 
 
 export const ReadInput = Schema.Struct({
 export const ReadInput = Schema.Struct({
   path: RelativePath,
   path: RelativePath,
@@ -28,39 +30,23 @@ export const ListInput = Schema.Struct({
 })
 })
 export type ListInput = typeof ListInput.Type
 export type ListInput = typeof ListInput.Type
 
 
-export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
-  path: RelativePath,
-  uri: Schema.String,
-  type: Schema.Literals(["file", "directory"]),
-  mime: Schema.String,
-}) {}
-
-export const FindInput = Schema.Struct({
+export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
   query: Schema.String,
   query: Schema.String,
   type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
   type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
   limit: PositiveInt.pipe(Schema.optional),
   limit: PositiveInt.pipe(Schema.optional),
-})
-export type FindInput = typeof FindInput.Type
+}) {}
 
 
-export const GrepInput = Schema.Struct({
+export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
   pattern: Schema.String,
   pattern: Schema.String,
-  include: Schema.String.pipe(Schema.optional),
+  path: RelativePath.pipe(Schema.optional),
   limit: PositiveInt.pipe(Schema.optional),
   limit: PositiveInt.pipe(Schema.optional),
-})
-export type GrepInput = typeof GrepInput.Type
+}) {}
 
 
-export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
-  path: RelativePath,
-  lines: Schema.String,
-  line: PositiveInt,
-  offset: NonNegativeInt,
-  submatches: Schema.Array(
-    Schema.Struct({
-      text: Schema.String,
-      start: NonNegativeInt,
-      end: NonNegativeInt,
-    }),
-  ),
+export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
+  pattern: Schema.String,
+  path: RelativePath.pipe(Schema.optional),
+  include: Schema.String.pipe(Schema.optional),
+  limit: PositiveInt.pipe(Schema.optional),
 }) {}
 }) {}
 
 
 export const Event = {
 export const Event = {
@@ -76,17 +62,18 @@ export interface Interface {
   readonly read: (input: ReadInput) => Effect.Effect<Content>
   readonly read: (input: ReadInput) => Effect.Effect<Content>
   readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
   readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
   readonly find: (input: FindInput) => Effect.Effect<Entry[]>
   readonly find: (input: FindInput) => Effect.Effect<Entry[]>
-  readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
+  readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
+  readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
 }
 }
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
 
 
-export const layer = Layer.effect(
+const baseLayer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const location = yield* Location.Service
     const location = yield* Location.Service
-    const search = yield* Search.Service
+    const search = yield* FileSystemSearch.Service
     const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
     const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
     const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
     const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
       const absolute = path.resolve(location.directory, input ?? ".")
       const absolute = path.resolve(location.directory, input ?? ".")
@@ -96,22 +83,10 @@ export const layer = Layer.effect(
       if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
       if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
       return { absolute, real, directory: location.directory, root }
       return { absolute, real, directory: location.directory, root }
     })
     })
-    const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
-      const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
-      if (!real) return
-      if (!FSUtil.contains(selected.root, real)) return
-      const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
-      const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
-      if (!type) return
-      return new Entry({
-        path: RelativePath.make(path.relative(selected.directory, absolute)),
-        uri: pathToFileURL(real).href,
-        type,
-        mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
-      })
-    })
-
     return Service.of({
     return Service.of({
+      find: search.find,
+      glob: search.glob,
+      grep: search.grep,
       read: Effect.fn("FileSystem.read")(function* (input) {
       read: Effect.fn("FileSystem.read")(function* (input) {
         const target = yield* resolve(input.path)
         const target = yield* resolve(input.path)
         const info = yield* fs.stat(target.real).pipe(Effect.orDie)
         const info = yield* fs.stat(target.real).pipe(Effect.orDie)
@@ -145,62 +120,28 @@ export const layer = Layer.effect(
         if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
         if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
         return yield* fs.readDirectoryEntries(target.real).pipe(
         return yield* fs.readDirectoryEntries(target.real).pipe(
           Effect.orDie,
           Effect.orDie,
-          Effect.flatMap((items) =>
-            Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), {
-              concurrency: "unbounded",
-            }),
-          ),
           Effect.map((items) =>
           Effect.map((items) =>
             items
             items
-              .filter((item): item is Entry => item !== undefined)
+              .flatMap((item) => {
+                if (item.type !== "file" && item.type !== "directory") return []
+                const absolute = path.join(target.absolute, item.name)
+                const relative = path.relative(target.directory, absolute)
+                return [
+                  new Entry({
+                    path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
+                    type: item.type,
+                    mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
+                  }),
+                ]
+              })
               .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
               .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
           ),
           ),
         )
         )
       }),
       }),
-      find: Effect.fn("FileSystem.find")(function* (input) {
-        const found = yield* search
-          .file({
-            cwd: location.directory,
-            query: input.query,
-            limit: input.limit,
-            kind: input.type ?? "all",
-          })
-          .pipe(Effect.orDie)
-        return found.map(
-          (item) =>
-            new Entry({
-              path: RelativePath.make(item.path),
-              uri: pathToFileURL(path.join(location.directory, item.path)).href,
-              type: item.type,
-              mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path),
-            }),
-        )
-      }),
-      grep: Effect.fn("FileSystem.grep")(function* (input) {
-        return (yield* search
-          .search({
-            cwd: location.directory,
-            pattern: input.pattern,
-            glob: input.include ? [input.include] : undefined,
-            limit: input.limit,
-          })
-          .pipe(Effect.orDie)).items.map(
-          (item) =>
-            new GrepMatch({
-              path: RelativePath.make(item.path.text),
-              lines: item.lines.text,
-              line: item.line_number,
-              offset: item.absolute_offset,
-              submatches: item.submatches.map((submatch) => ({
-                text: submatch.match.text,
-                start: submatch.start,
-                end: submatch.end,
-              })),
-            }),
-        )
-      }),
     })
     })
   }),
   }),
 )
 )
 
 
+export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
+
 export const locationLayer = layer
 export const locationLayer = layer

+ 0 - 487
packages/core/src/filesystem/ripgrep.ts

@@ -1,487 +0,0 @@
-import path from "path"
-import { serviceUse } from "../effect/service-use"
-import { FSUtil } from "../fs-util"
-import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
-import type { PlatformError } from "effect/PlatformError"
-import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
-import { ChildProcess } from "effect/unstable/process"
-import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
-
-import { CrossSpawnSpawner } from "../cross-spawn-spawner"
-import { Global } from "../global"
-import { NonNegativeInt } from "../schema"
-import { which } from "../util/which"
-import { LayerNode } from "../effect/layer-node"
-import { httpClient } from "../effect/layer-node-platform"
-
-const VERSION = "15.1.0"
-const PLATFORM = {
-  "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
-  "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
-  "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
-  "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
-  "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
-  "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
-  "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
-} as const
-
-const TimeStats = Schema.Struct({
-  secs: NonNegativeInt,
-  nanos: NonNegativeInt,
-  human: Schema.String,
-})
-
-const Stats = Schema.Struct({
-  elapsed: TimeStats,
-  searches: NonNegativeInt,
-  searches_with_match: NonNegativeInt,
-  bytes_searched: NonNegativeInt,
-  bytes_printed: NonNegativeInt,
-  matched_lines: NonNegativeInt,
-  matches: NonNegativeInt,
-})
-
-const PathText = Schema.Struct({
-  text: Schema.String,
-})
-
-const Begin = Schema.Struct({
-  type: Schema.Literal("begin"),
-  data: Schema.Struct({
-    path: PathText,
-  }),
-})
-
-export const SearchMatch = Schema.Struct({
-  path: PathText,
-  lines: Schema.Struct({
-    text: Schema.String,
-  }),
-  line_number: NonNegativeInt,
-  absolute_offset: NonNegativeInt,
-  submatches: Schema.Array(
-    Schema.Struct({
-      match: Schema.Struct({
-        text: Schema.String,
-      }),
-      start: NonNegativeInt,
-      end: NonNegativeInt,
-    }),
-  ),
-})
-
-export const Match = Schema.Struct({
-  type: Schema.Literal("match"),
-  data: SearchMatch,
-})
-
-const End = Schema.Struct({
-  type: Schema.Literal("end"),
-  data: Schema.Struct({
-    path: PathText,
-    binary_offset: Schema.NullOr(NonNegativeInt),
-    stats: Stats,
-  }),
-})
-
-const Summary = Schema.Struct({
-  type: Schema.Literal("summary"),
-  data: Schema.Struct({
-    elapsed_total: TimeStats,
-    stats: Stats,
-  }),
-})
-
-const Result = Schema.Union([Begin, Match, End, Summary])
-const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
-
-export type Result = Schema.Schema.Type<typeof Result>
-export type Match = Schema.Schema.Type<typeof Match>
-export type Item = Match["data"]
-export type Begin = Schema.Schema.Type<typeof Begin>
-export type End = Schema.Schema.Type<typeof End>
-export type Summary = Schema.Schema.Type<typeof Summary>
-export type Row = Match["data"]
-
-export interface SearchResult {
-  items: Item[]
-  partial: boolean
-}
-
-export interface FilesInput {
-  cwd: string
-  glob?: string[]
-  hidden?: boolean
-  follow?: boolean
-  maxDepth?: number
-  signal?: AbortSignal
-}
-
-export interface SearchInput {
-  cwd: string
-  pattern: string
-  glob?: string[]
-  limit?: number
-  follow?: boolean
-  file?: string[]
-  signal?: AbortSignal
-}
-
-export interface TreeInput {
-  cwd: string
-  limit?: number
-  signal?: AbortSignal
-}
-
-export interface Interface {
-  readonly filepath: Effect.Effect<string, Error>
-  readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
-  readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
-  readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
-
-export const use = serviceUse(Service)
-
-function env() {
-  const env = Object.fromEntries(
-    Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
-  )
-  delete env.RIPGREP_CONFIG_PATH
-  return env
-}
-
-function aborted(signal?: AbortSignal) {
-  const err = signal?.reason
-  if (err instanceof Error) return err
-  const out = new Error("Aborted")
-  out.name = "AbortError"
-  return out
-}
-
-function waitForAbort(signal?: AbortSignal) {
-  if (!signal) return Effect.never
-  if (signal.aborted) return Effect.fail(aborted(signal))
-  return Effect.callback<never, Error>((resume) => {
-    const onabort = () => resume(Effect.fail(aborted(signal)))
-    signal.addEventListener("abort", onabort, { once: true })
-    return Effect.sync(() => signal.removeEventListener("abort", onabort))
-  })
-}
-
-function error(stderr: string, code: number) {
-  const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
-  err.name = "RipgrepError"
-  return err
-}
-
-function clean(file: string) {
-  return path.normalize(file.replace(/^\.[\\/]/, ""))
-}
-
-function row(data: Row): Row {
-  return {
-    ...data,
-    path: {
-      ...data.path,
-      text: clean(data.path.text),
-    },
-  }
-}
-
-function parse(line: string) {
-  return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
-}
-
-function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
-  Queue.failCauseUnsafe(queue, Cause.fail(err))
-}
-
-function filesArgs(input: FilesInput) {
-  const args = ["--no-config", "--files", "--glob=!.git/*"]
-  if (input.follow) args.push("--follow")
-  if (input.hidden !== false) args.push("--hidden")
-  if (input.hidden === false) args.push("--glob=!.*")
-  if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
-  if (input.glob) {
-    for (const glob of input.glob) args.push(`--glob=${glob}`)
-  }
-  args.push(".")
-  return args
-}
-
-function searchArgs(input: SearchInput) {
-  const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
-  if (input.follow) args.push("--follow")
-  if (input.glob) {
-    for (const glob of input.glob) args.push(`--glob=${glob}`)
-  }
-  if (input.limit) args.push(`--max-count=${input.limit}`)
-  args.push("--", input.pattern, ...(input.file ?? ["."]))
-  return args
-}
-
-function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
-  return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
-}
-
-export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
-  Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* FSUtil.Service
-      const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
-      const spawner = yield* ChildProcessSpawner
-
-      const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
-        const handle = yield* spawner.spawn(
-          ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
-        )
-        const [stdout, stderr, code] = yield* Effect.all(
-          [
-            Stream.mkString(Stream.decodeText(handle.stdout)),
-            Stream.mkString(Stream.decodeText(handle.stderr)),
-            handle.exitCode,
-          ],
-          { concurrency: "unbounded" },
-        )
-        return { stdout, stderr, code }
-      }, Effect.scoped)
-
-      const extract = Effect.fnUntraced(function* (
-        archive: string,
-        config: (typeof PLATFORM)[keyof typeof PLATFORM],
-        target: string,
-      ) {
-        const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
-
-        if (config.extension === "zip") {
-          const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
-          const result = yield* run(shell, [
-            "-NoProfile",
-            "-NonInteractive",
-            "-Command",
-            `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
-          ])
-          if (result.code !== 0) {
-            return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
-          }
-        }
-
-        if (config.extension === "tar.gz") {
-          const result = yield* run("tar", ["-xzf", archive, "-C", dir])
-          if (result.code !== 0) {
-            return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
-          }
-        }
-
-        const extracted = path.join(
-          dir,
-          `ripgrep-${VERSION}-${config.platform}`,
-          process.platform === "win32" ? "rg.exe" : "rg",
-        )
-        if (!(yield* fs.isFile(extracted))) {
-          return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
-        }
-
-        yield* fs.copyFile(extracted, target)
-        if (process.platform === "win32") return
-        yield* fs.chmod(target, 0o755)
-      }, Effect.scoped)
-
-      const filepath = yield* Effect.cached(
-        Effect.gen(function* () {
-          const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
-          if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
-
-          const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
-          if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
-
-          const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
-          const config = PLATFORM[platformKey]
-          if (!config) {
-            return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
-          }
-
-          const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
-          const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
-          const archive = path.join(Global.Path.bin, filename)
-
-          yield* Effect.logInfo("downloading ripgrep", { url })
-          yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
-
-          const bytes = yield* HttpClientRequest.get(url).pipe(
-            http.execute,
-            Effect.flatMap((response) => response.arrayBuffer),
-            Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
-          )
-          if (bytes.byteLength === 0) {
-            return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
-          }
-
-          yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
-          yield* extract(archive, config, target)
-          yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
-          return target
-        }),
-      )
-
-      const check = Effect.fnUntraced(function* (cwd: string) {
-        if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
-        return yield* Effect.fail(
-          Object.assign(new Error(`No such file or directory: '${cwd}'`), {
-            code: "ENOENT",
-            errno: -2,
-            path: cwd,
-          }),
-        )
-      })
-
-      const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
-        const binary = yield* filepath
-        return ChildProcess.make(binary, args, {
-          cwd,
-          env: env(),
-          extendEnv: true,
-          stdin: "ignore",
-        })
-      })
-
-      const files: Interface["files"] = (input) =>
-        Stream.callback<string, PlatformError | Error>((queue) =>
-          Effect.gen(function* () {
-            yield* Effect.forkScoped(
-              Effect.gen(function* () {
-                yield* check(input.cwd)
-                const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
-                const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
-                const stdout = yield* Stream.decodeText(handle.stdout).pipe(
-                  Stream.splitLines,
-                  Stream.filter((line) => line.length > 0),
-                  Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
-                  Effect.forkScoped,
-                )
-                const code = yield* raceAbort(handle.exitCode, input.signal)
-                yield* Fiber.join(stdout)
-                if (code === 0 || code === 1) {
-                  Queue.endUnsafe(queue)
-                  return
-                }
-                fail(queue, error(yield* Fiber.join(stderr), code))
-              }).pipe(
-                Effect.catch((err) =>
-                  Effect.sync(() => {
-                    fail(queue, err)
-                  }),
-                ),
-              ),
-            )
-          }),
-        )
-
-      const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
-        yield* check(input.cwd)
-
-        const program = Effect.scoped(
-          Effect.gen(function* () {
-            const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
-
-            const [items, stderr, code] = yield* Effect.all(
-              [
-                Stream.decodeText(handle.stdout).pipe(
-                  Stream.splitLines,
-                  Stream.filter((line) => line.length > 0),
-                  Stream.mapEffect(parse),
-                  Stream.filter((item): item is Match => item.type === "match"),
-                  Stream.map((item) => row(item.data)),
-                  Stream.runCollect,
-                  Effect.map((chunk) => [...chunk]),
-                ),
-                Stream.mkString(Stream.decodeText(handle.stderr)),
-                handle.exitCode,
-              ],
-              { concurrency: "unbounded" },
-            )
-
-            if (code !== 0 && code !== 1 && code !== 2) {
-              return yield* Effect.fail(error(stderr, code))
-            }
-
-            return {
-              items: code === 1 ? [] : items,
-              partial: code === 2,
-            }
-          }),
-        )
-
-        return yield* raceAbort(program, input.signal)
-      })
-
-      const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
-        yield* Effect.logInfo("tree", input)
-        const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
-
-        interface Node {
-          name: string
-          children: Map<string, Node>
-        }
-
-        function child(node: Node, name: string) {
-          const item = node.children.get(name)
-          if (item) return item
-          const next = { name, children: new Map() }
-          node.children.set(name, next)
-          return next
-        }
-
-        function count(node: Node): number {
-          return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
-        }
-
-        const root: Node = { name: "", children: new Map() }
-        for (const file of list) {
-          if (file.includes(".opencode")) continue
-          const parts = file.split(path.sep)
-          if (parts.length < 2) continue
-          let node = root
-          for (const part of parts.slice(0, -1)) {
-            node = child(node, part)
-          }
-        }
-
-        const total = count(root)
-        const limit = input.limit ?? total
-        const lines: string[] = []
-        const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
-          .sort((a, b) => a.name.localeCompare(b.name))
-          .map((node) => ({ node, path: node.name }))
-
-        let used = 0
-        for (let i = 0; i < queue.length && used < limit; i++) {
-          const item = queue[i]
-          lines.push(item.path)
-          used++
-          queue.push(
-            ...Array.from(item.node.children.values())
-              .sort((a, b) => a.name.localeCompare(b.name))
-              .map((node) => ({ node, path: `${item.path}/${node.name}` })),
-          )
-        }
-
-        if (total > used) lines.push(`[${total - used} truncated]`)
-        return lines.join("\n")
-      })
-
-      return Service.of({ filepath, files, tree, search })
-    }),
-  )
-
-export const defaultLayer = layer.pipe(
-  Layer.provide(FetchHttpClient.layer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(CrossSpawnSpawner.defaultLayer),
-)
-export const node = LayerNode.make(layer, [FSUtil.node, CrossSpawnSpawner.node, httpClient])
-
-export * as Ripgrep from "./ripgrep"

+ 23 - 0
packages/core/src/filesystem/schema.ts

@@ -0,0 +1,23 @@
+import { Schema } from "effect"
+import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
+
+export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
+  path: RelativePath,
+  type: Schema.Literals(["file", "directory"]),
+  mime: Schema.String,
+}) {}
+
+export const Submatch = Schema.Struct({
+  text: Schema.String,
+  start: NonNegativeInt,
+  end: NonNegativeInt,
+})
+export type Submatch = typeof Submatch.Type
+
+export class Match extends Schema.Class<Match>("FileSystem.Match")({
+  entry: Entry,
+  line: PositiveInt,
+  offset: NonNegativeInt,
+  text: Schema.String,
+  submatches: Schema.Array(Submatch),
+}) {}

+ 206 - 543
packages/core/src/filesystem/search.ts

@@ -1,567 +1,230 @@
+export * as FileSystemSearch from "./search"
+
 import path from "path"
 import path from "path"
-import { Context, Deferred, Effect, Layer, Option, Stream } from "effect"
-import type { PlatformError } from "effect/PlatformError"
-import { FSUtil } from "../fs-util"
-import { Glob } from "../util/glob"
-import { Global } from "../global"
-import { serviceUse } from "../effect/service-use"
-import { makeRuntime } from "../effect/runtime"
+import { Context, Effect, Fiber, Layer, Scope } from "effect"
 import { Fff } from "#fff"
 import { Fff } from "#fff"
-import { Ripgrep } from "./ripgrep"
-import { LayerNode } from "../effect/layer-node"
-
-const root = path.join(Global.Path.cache, "fff")
-
-export type Item = Ripgrep.Item
-export type SearchError = PlatformError | globalThis.Error
-
-export interface Result {
-  readonly items: Item[]
-  readonly partial: boolean
-  readonly hasNextPage: boolean
-  readonly engine: "fff" | "ripgrep"
-  readonly regexFallbackError?: string
-}
-
-export interface FileInput {
-  readonly cwd: string
-  readonly query: string
-  readonly limit?: number
-  readonly current?: string
-  readonly kind?: "file" | "directory" | "all"
-}
-
-export interface FileResult {
-  readonly path: string
-  readonly type: "file" | "directory"
-}
-
-export interface GlobInput {
-  readonly cwd: string
-  readonly pattern: string
-  readonly limit?: number
-  readonly signal?: AbortSignal
-}
-
-interface Query {
-  readonly dir: string
-  readonly text: string
-  readonly files: string[]
-}
-
-// A created picker plus its cached scan-readiness gate. The picker is created
-// (and its native background scan kicked off) eagerly; `ready` is only awaited
-// when the picker is actually used.
-interface Picker {
-  readonly pick: Fff.Picker
-  readonly ready: Effect.Effect<void, Error>
-}
-
-interface State {
-  readonly pick: Map<string, Picker>
-  readonly wait: Map<string, Deferred.Deferred<Picker, Error>>
-  readonly recent: Query[]
-}
+import fuzzysort from "fuzzysort"
+import { FileSystem } from "../filesystem"
+import { FSUtil } from "../fs-util"
+import { Location } from "../location"
+import { Ripgrep } from "../ripgrep"
+import { RelativePath } from "../schema"
 
 
 export interface Interface {
 export interface Interface {
-  readonly files: Ripgrep.Interface["files"]
-  readonly tree: Ripgrep.Interface["tree"]
-  readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
-  readonly file: (input: FileInput) => Effect.Effect<readonly FileResult[], SearchError>
-  readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
-  readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
-  readonly warm: (cwd: string) => Effect.Effect<void>
-  // Destroy the picker for a directory and drop its cached state. Called when a
-  // directory's instance is disposed so fff's native watcher thread is torn
-  // down instead of leaking until process exit.
-  readonly release: (cwd: string) => Effect.Effect<void>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
-
-export const use = serviceUse(Service)
-
-function key(dir: string) {
-  return Buffer.from(dir).toString("base64url")
+  readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
+  readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
+  readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
 }
 }
 
 
-function fffSync<A>(action: string, run: () => A) {
-  return Effect.try({
-    try: run,
-    catch: (cause) => new Error(`fff ${action} failed`, { cause }),
-  })
-}
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem/Search") {}
 
 
-function normalize(text: string) {
-  return text.replaceAll("\\", "/")
-}
-
-// fff supports glob narrowing for any search out of the box
-function fffGlobbedQuery(query: string, glob?: string | string[]) {
-  if (query && glob) {
-    const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
-    return `${resolvedGlob} ${query}`
-  }
-
-  return query ?? glob
-}
-
-function remember(state: State, dir: string, text: string, files: string[]) {
-  if (!files.length) return
-  const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64)
-  if (!next.length) return
-  const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
-  if (idx >= 0) state.recent.splice(idx, 1)
-  state.recent.unshift({ dir, text, files: next })
-  if (state.recent.length > 32) state.recent.length = 32
-}
-
-function item(hit: Fff.Hit): Item {
-  const line = Buffer.from(hit.lineContent)
-  return {
-    path: { text: normalize(hit.relativePath) },
-    lines: { text: hit.lineContent },
-    line_number: hit.lineNumber,
-    absolute_offset: hit.byteOffset,
-    submatches: hit.matchRanges
-      .map(([start, end]) => {
-        const text = line.subarray(start, end).toString("utf8")
-        if (!text) return undefined
-        return {
-          match: { text },
-          start,
-          end,
-        }
-      })
-      .filter((row): row is Item["submatches"][number] => Boolean(row)),
-  }
-}
-
-function collectPaths<T>(
-  items: T[],
-  scores: Array<{ total: number }>,
-  toResult: (item: T) => FileResult,
-): FileResult[] {
-  const rows = items.flatMap((item, index): Array<FileResult & { score: number }> => {
-    const result = toResult(item)
-    if (!result.path) return []
-    return [{ ...result, score: scores[index]?.total ?? 0 }]
-  })
-  rows.sort(
-    (a, b) => b.score - a.score || a.path.length - b.path.length || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
-  )
-
-  const seen = new Set<string>()
-  return rows.flatMap((item) => {
-    if (seen.has(item.path)) return []
-    seen.add(item.path)
-    return [{ path: item.path, type: item.type }]
-  })
-}
-
-function searchFff(
-  pick: Fff.Picker,
-  kind: "file" | "directory" | "all",
-  query: string,
-  opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
-): Fff.Result<FileResult[]> {
-  if (kind === "directory") {
-    const out = pick.directorySearch(query, opts)
-    if (!out.ok) return out
-    return {
-      ok: true,
-      value: collectPaths(out.value.items, out.value.scores, (entry) => ({
-        path: normalize(entry.relativePath),
-        type: "directory",
-      })),
-    }
-  }
-  if (kind === "all") {
-    const out = pick.mixedSearch(query, opts)
-    if (!out.ok) return out
-    return {
-      ok: true,
-      value: collectPaths(out.value.items, out.value.scores, (entry) => ({
-        path: normalize(entry.item.relativePath),
-        type: entry.type,
-      })),
-    }
-  }
-  const out = pick.fileSearch(query, opts)
-  if (!out.ok) return out
-  return {
-    ok: true,
-    value: collectPaths(out.value.items, out.value.scores, (entry) => ({
-      path: normalize(entry.relativePath),
-      type: "file",
-    })),
-  }
-}
-
-export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service> = Layer.effect(
+export const ripgrepLayer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
-    const rg = yield* Ripgrep.Service
-
-    const state: State = {
-      pick: new Map<string, Picker>(),
-      wait: new Map<string, Deferred.Deferred<Picker, Error>>(),
-      recent: [] as Query[],
+    const location = yield* Location.Service
+    const ripgrep = yield* Ripgrep.Service
+    const scope = yield* Scope.Scope
+    const state = {
+      files: [] as string[],
+      directories: [] as string[],
+      scan: undefined as Fiber.Fiber<void, never> | undefined,
     }
     }
-
-    yield* fs.ensureDir(root).pipe(Effect.ignore)
-    yield* Effect.addFinalizer(() =>
-      Effect.forEach(
-        state.pick.values(),
-        (entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore),
-        { discard: true },
+    state.scan = yield* ripgrep.find({ cwd: location.directory, pattern: "*", limit: 100_000 }).pipe(
+      Effect.tap((result) =>
+        Effect.sync(() => {
+          state.files = result.map((item) => item.path)
+          state.directories = Array.from(
+            new Set(
+              state.files.flatMap((file) => {
+                const parts = file.split("/")
+                return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/") + path.sep)
+              }),
+            ),
+          )
+        }),
       ),
       ),
+      Effect.orDie,
+      Effect.asVoid,
+      Effect.forkIn(scope),
     )
     )
-
-    const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
-      const out = yield* rg.search(input)
-      return {
-        items: out.items,
-        partial: out.partial,
-        hasNextPage: false,
-        engine: "ripgrep" as const,
-      }
-    })
-
-    // Lazy, shared scan-wait for a picker. Preserves the original behavior: if
-    // the scan does not finish within the budget the picker is destroyed and
-    // dropped from the cache so callers fall back to ripgrep (and the next
-    // request recreates a fresh picker).
-    const scanReady = (dir: string, pick: Fff.Picker) =>
-      Effect.gen(function* () {
-        const scanned = yield* Effect.tryPromise({
-          try: () => pick.waitForScan(5_000),
-          catch: (cause) => new Error("fff waitForScan failed", { cause }),
-        })
-        if (!scanned.ok || !scanned.value) {
-          yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
-          state.pick.delete(dir)
-          yield* Effect.logWarning("fff scan not ready", { dir })
-          return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
-        }
-
-        const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
-        if (!git.ok) {
-          yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error })
-        }
-      })
-
-    // Create (or return) the picker for a directory. Creation is synchronous
-    // and does not await the scan; the native background scan starts as soon as
-    // the picker exists. The `wait` gate dedupes concurrent creation.
-    const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
-      const dir = FSUtil.resolve(cwd)
-      const existing = state.pick.get(dir)
-      if (existing) return existing
-
-      const pending = state.wait.get(dir)
-      if (pending) return yield* Deferred.await(pending)
-
-      const available = yield* fffSync("check availability", () => Fff.available()).pipe(
-        Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))),
-      )
-      if (!available) return undefined
-
-      const gate = yield* Deferred.make<Picker, Error>()
-      state.wait.set(dir, gate)
-      return yield* Effect.gen(function* () {
-        const id = key(dir)
-        const isFirstPicker = state.pick.size === 0
-        const made = yield* fffSync("create picker", () =>
-          Fff.create({
-            basePath: dir,
-            frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
-            historyDbPath: path.join(root, `${id}.history.mdb`),
-            aiMode: true,
-            // only the first toolcall picker can accumulate resources to index
-            // home directory, if the user specifically opened opencode at the
-            // $HOME level or asked it to search there on purpose, otherwise fallback
-            enableHomeDirScanning: isFirstPicker,
-            // on unix system it is 99.9% that you do not need to search for the
-            // content at the / so make fff fail creation and fallback to rg
-            enableFsRootScanning: isFirstPicker && process.platform === "win32",
-          }),
-        )
-        if (!made.ok) {
-          yield* Effect.logWarning("fff init failed", { dir, error: made.error })
-          const err = new Error(made.error)
-          yield* Deferred.fail(gate, err)
-          return yield* Effect.fail(err)
-        }
-
-        const pick = made.value
-        const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) }
-        state.pick.set(dir, entry)
-        yield* Deferred.succeed(gate, entry)
-        return entry
-      }).pipe(
-        Effect.ensuring(
-          Effect.gen(function* () {
-            if (state.wait.get(dir) === gate) state.wait.delete(dir)
-            yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
-          }),
-        ),
-      )
-    })
-
-    // Resolve a usable, scanned picker for a directory, or undefined when fff is
-    // unavailable or the scan did not become ready.
-    const picker = Effect.fn("Search.picker")(function* (cwd: string) {
-      const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
-      if (!entry) return undefined
-      const ready = yield* entry.ready.pipe(
-        Effect.as(true),
-        Effect.catch(() => Effect.succeed(false)),
-      )
-      if (!ready) return undefined
-      return entry.pick
-    })
-
-    const files: Interface["files"] = (input) => rg.files(input)
-    const tree: Interface["tree"] = (input) => rg.tree(input)
-
-    // in 99% of use cases user that is opened opencode at certain directory will
-    // conduct a file search in this direcotry, it could be switched later but
-    // mostly always we will need a file picker for cwd
-    // so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
-    const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) {
-      yield* acquire(cwd).pipe(Effect.ignore)
-    })
-
-    // Tear down the picker for a directory. fff pickers own a native background
-    // watcher thread that otherwise lives until the runtime scope closes (i.e.
-    // process exit), so disposing the instance that warmed it must destroy it
-    // here or the thread leaks against a directory that may already be gone.
-    const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) {
-      const dir = FSUtil.resolve(cwd)
-
-      const pending = state.wait.get(dir)
-      if (pending) {
-        state.wait.delete(dir)
-        yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore)
-      }
-
-      const entry = state.pick.get(dir)
-      if (entry) {
-        state.pick.delete(dir)
-        yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore)
-      }
-
-      const remaining = state.recent.filter((item) => item.dir !== dir)
-      state.recent.splice(0, state.recent.length, ...remaining)
-    })
-
-    const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
-      const query = input.query.trim()
-      const kind = input.kind ?? "file"
-
-      const entry = yield* acquire(input.cwd)
-      if (!entry) return yield* Effect.fail(new Error("fff is unavailable"))
-      yield* entry.ready
-      const dir = FSUtil.resolve(input.cwd)
-      const limit = input.limit ?? 100
-      const fffResult = yield* fffSync(`${kind} search`, () =>
-        searchFff(entry.pick, kind, query, {
-          pageIndex: 0,
-          currentFile: input.current, // supports both relative and absolute (relative preferred)
-          pageSize: limit,
+    return Service.of({
+      glob: (input) =>
+        Effect.gen(function* () {
+          const target = path.resolve(location.directory, input.path ?? ".")
+          const info = yield* fs.stat(target).pipe(Effect.orDie)
+          const cwd = info.type === "File" ? path.dirname(target) : target
+          return yield* ripgrep
+            .glob({
+              cwd,
+              pattern: input.pattern,
+              limit: input.limit ?? Number.MAX_SAFE_INTEGER,
+            })
+            .pipe(
+              Effect.map((result) =>
+                result.map(
+                  (entry) =>
+                    new FileSystem.Entry({
+                      ...entry,
+                      path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
+                    }),
+                ),
+              ),
+              Effect.orDie,
+            )
         }),
         }),
-      ).pipe(
-        Effect.catch((error) =>
-          Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
-            Effect.andThen(Effect.fail(error)),
-          ),
-        ),
-      )
-      if (!fffResult.ok) {
-        yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
-        return yield* Effect.fail(new Error(fffResult.error))
-      }
-
-      const rows = fffResult.value
-      remember(
-        state,
-        dir,
-        query,
-        rows.map((row) => path.join(dir, row.path)),
-      )
-      return rows.slice(0, limit)
-    })
-
-    const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
-      input.signal?.throwIfAborted()
-      if (input.file?.length) return yield* rip(input)
-
-      const pick = yield* picker(input.cwd)
-      if (!pick) return yield* rip(input)
-
-      const dir = FSUtil.resolve(input.cwd)
-      const limit = input.limit ?? 100
-
-      const fffGrep = yield* fffSync("grep", () =>
-        pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
-          mode: "regex",
-          pageSize: limit,
-          timeBudgetMs: 1_500,
+      grep: (input) =>
+        Effect.gen(function* () {
+          const target = path.resolve(location.directory, input.path ?? ".")
+          const info = yield* fs.stat(target).pipe(Effect.orDie)
+          const cwd = info.type === "File" ? path.dirname(target) : target
+          return yield* ripgrep
+            .grep({
+              cwd,
+              pattern: input.pattern,
+              file: info.type === "File" ? path.basename(target) : undefined,
+              include: input.include,
+              limit: input.limit ?? Number.MAX_SAFE_INTEGER,
+            })
+            .pipe(
+              Effect.map((result) =>
+                result.map(
+                  (match) =>
+                    new FileSystem.Match({
+                      ...match,
+                      entry: new FileSystem.Entry({
+                        ...match.entry,
+                        path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
+                      }),
+                    }),
+                ),
+              ),
+              Effect.orDie,
+            )
+        }),
+      find: (input) =>
+        Effect.gen(function* () {
+          if (input.query) yield* Fiber.join(state.scan!)
+          const items =
+            input.type === "file"
+              ? state.files
+              : input.type === "directory"
+                ? state.directories
+                : [...state.files, ...state.directories]
+          return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
+            const relative = item.target
+            const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
+            const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
+            const absolute = path.resolve(location.directory, clean)
+            return new FileSystem.Entry({
+              path: RelativePath.make(relative),
+              type,
+              mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
+            })
+          })
         }),
         }),
-      ).pipe(
-        Effect.catch((error) =>
-          Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe(
-            Effect.as<Fff.Result<Fff.Grep> | undefined>(undefined),
-          ),
-        ),
-      )
-      if (!fffGrep) return yield* rip(input)
-      if (!fffGrep.ok) {
-        yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
-        return yield* rip(input)
-      }
-
-      const rows: Item[] = fffGrep.value.items.map(item)
-      const regexFallbackError = fffGrep.value.regexFallbackError
-
-      remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
-
-      return {
-        items: rows,
-        partial: false,
-        hasNextPage: !!fffGrep.value.nextCursor,
-        engine: "fff" as const,
-        regexFallbackError,
-      }
     })
     })
+  }),
+)
 
 
-    const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
-      input.signal?.throwIfAborted()
-
-      const dir = FSUtil.resolve(input.cwd)
-      const limit = input.limit ?? 100
-      const pick = yield* picker(dir)
-
-      if (pick) {
-        const fffGlob = yield* fffSync("glob file search", () =>
-          pick.glob(normalize(input.pattern), {
+export const fffLayer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const location = yield* Location.Service
+    const result = yield* Effect.try({
+      try: () =>
+        Fff.create({
+          basePath: location.directory,
+          aiMode: true,
+          enableFsRootScanning: true,
+          enableHomeDirScanning: true,
+        }),
+      catch: (cause) => cause,
+    }).pipe(Effect.orDie)
+    if (!result.ok) return yield* Effect.die(result.error)
+    yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
+    const scanned = yield* Effect.tryPromise({
+      try: () => result.value.waitForScan(5_000),
+      catch: (cause) => cause,
+    }).pipe(Effect.orDie)
+    if (!scanned.ok || !scanned.value) return yield* Effect.die(scanned.ok ? "fff scan timed out" : scanned.error)
+    return Service.of({
+      glob: (input) =>
+        Effect.sync(() => {
+          const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
+          const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
             pageIndex: 0,
             pageIndex: 0,
-            pageSize: limit,
-          }),
-        ).pipe(
-          Effect.catch((error) =>
-            Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe(
-              Effect.as<Fff.Result<Fff.Search> | undefined>(undefined),
-            ),
-          ),
-        )
-
-        if (fffGlob?.ok) {
-          const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath))))
-
-          remember(
-            state,
-            dir,
-            input.pattern,
-            rows.map((row) => path.join(dir, row)),
+            pageSize: input.limit,
+          })
+          if (!found.ok) throw found.error
+          return found.value.items.map((item) => {
+            const absolute = path.resolve(location.directory, item.relativePath)
+            return new FileSystem.Entry({
+              path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
+              type: "file",
+              mime: FSUtil.mimeType(absolute),
+            })
+          })
+        }),
+      grep: (input) =>
+        Effect.sync(() => {
+          const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
+          const found = result.value.grep(
+            [prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
+              .filter((value) => value !== undefined)
+              .join(" "),
+            { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
           )
           )
-
-          return {
-            files: rows.slice(0, limit).map((row) => path.join(dir, row)),
-            truncated: fffGlob.value.totalMatched > rows.length,
-          }
-        } else if (fffGlob) {
-          yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
-          // fall through to the fallback
-        }
-      }
-
-      const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
-        Stream.take(limit + 1),
-        Stream.runCollect,
-        Effect.map((chunk) => [...chunk]),
-      )
-      const truncated = rows.length > limit
-      if (truncated) rows.length = limit
-
-      const output = yield* Effect.forEach(
-        rows,
-        Effect.fnUntraced(function* (file) {
-          const full = path.join(dir, file)
-          const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
-          const time =
-            info?.mtime.pipe(
-              Option.map((item) => item.getTime()),
-              Option.getOrElse(() => 0),
-            ) ?? 0
-          return { file: full, time }
+          if (!found.ok) throw found.error
+          return found.value.items.map((match) => {
+            const bytes = Buffer.from(match.lineContent)
+            return new FileSystem.Match({
+              entry: new FileSystem.Entry({
+                path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
+                type: "file",
+                mime: FSUtil.mimeType(match.relativePath),
+              }),
+              line: match.lineNumber,
+              offset: match.byteOffset,
+              text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
+              submatches: match.matchRanges.map(([start, end]) => ({
+                text: bytes.subarray(start, end).toString("utf8"),
+                start,
+                end,
+              })),
+            })
+          })
+        }),
+      find: (input) =>
+        Effect.sync(() => {
+          const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
+          const items = (() => {
+            if (input.type === "file") {
+              const found = result.value.fileSearch(input.query.trim(), options)
+              if (!found.ok) throw found.error
+              return found.value.items.map((item) => ({ path: item.relativePath, type: "file" as const }))
+            }
+            if (input.type === "directory") {
+              const found = result.value.directorySearch(input.query.trim(), options)
+              if (!found.ok) throw found.error
+              return found.value.items.map((item) => ({ path: item.relativePath, type: "directory" as const }))
+            }
+            const found = result.value.mixedSearch(input.query.trim(), options)
+            if (!found.ok) throw found.error
+            return found.value.items.map((item) => ({ path: item.item.relativePath, type: item.type }))
+          })()
+          return items.map((item) => {
+            const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
+            const absolute = path.resolve(location.directory, relative)
+            return new FileSystem.Entry({
+              path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
+              type: item.type,
+              mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
+            })
+          })
         }),
         }),
-        { concurrency: 16 },
-      )
-      output.sort((a, b) => b.time - a.time)
-      return {
-        files: output.map((item) => item.file),
-        truncated,
-      }
-    })
-
-    const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
-      const file = input.cwd
-        ? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
-        : FSUtil.resolve(input.file)
-      const idx = state.recent.findIndex((item) => item.files.includes(file))
-      if (idx < 0) return
-
-      const row = state.recent[idx]
-      state.recent.splice(idx, 1)
-      const entry = state.pick.get(row.dir)
-      if (!entry) return
-
-      const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
-        Effect.catch((error) =>
-          Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe(
-            Effect.as<Fff.Result<boolean> | undefined>(undefined),
-          ),
-        ),
-      )
-      if (!out) return
-      if (!out.ok) {
-        yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
-      }
     })
     })
-
-    return Service.of({ files, tree, search, file, glob, open, warm, release })
   }),
   }),
 )
 )
 
 
-export const defaultLayer: Layer.Layer<Service> = layer.pipe(
-  Layer.provide(Ripgrep.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-)
-export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node])
-
-const { runPromise } = makeRuntime(Service, defaultLayer)
-
-export function tree(input: Ripgrep.TreeInput) {
-  return runPromise((svc) => svc.tree(input))
-}
-
-export function search(input: Ripgrep.SearchInput) {
-  return runPromise((svc) => svc.search(input))
-}
-
-export function file(input: FileInput) {
-  return runPromise((svc) => svc.file(input))
-}
-
-export function glob(input: GlobInput) {
-  return runPromise((svc) => svc.glob(input))
-}
-
-export function open(input: { cwd?: string; file: string }) {
-  return runPromise((svc) => svc.open(input))
-}
-
-export * as Search from "./search"
+export const defaultLayer = Layer.unwrap(Effect.sync(() => (Fff.available() ? fffLayer : ripgrepLayer)))

+ 5 - 18
packages/core/src/location-layer.ts

@@ -18,10 +18,9 @@ import { Database } from "./database/database"
 import { PermissionV2 } from "./permission"
 import { PermissionV2 } from "./permission"
 import { PermissionSaved } from "./permission/saved"
 import { PermissionSaved } from "./permission/saved"
 import { FileSystem } from "./filesystem"
 import { FileSystem } from "./filesystem"
+import { Ripgrep } from "./ripgrep"
 import { Watcher } from "./filesystem/watcher"
 import { Watcher } from "./filesystem/watcher"
-import { Search } from "./filesystem/search"
 import { LocationMutation } from "./location-mutation"
 import { LocationMutation } from "./location-mutation"
-import { LocationSearch } from "./location-search"
 import { FileMutation } from "./file-mutation"
 import { FileMutation } from "./file-mutation"
 import { Reference } from "./reference"
 import { Reference } from "./reference"
 import { RepositoryCache } from "./repository-cache"
 import { RepositoryCache } from "./repository-cache"
@@ -34,7 +33,6 @@ import { ToolRegistry } from "./tool/registry"
 import { ApplicationTools } from "./tool/application-tools"
 import { ApplicationTools } from "./tool/application-tools"
 import { ToolOutputStore } from "./tool-output-store"
 import { ToolOutputStore } from "./tool-output-store"
 import { AppProcess } from "./process"
 import { AppProcess } from "./process"
-import { Ripgrep } from "./ripgrep"
 import { SessionStore } from "./session/store"
 import { SessionStore } from "./session/store"
 import { SessionTodo } from "./session/todo"
 import { SessionTodo } from "./session/todo"
 import { QuestionV2 } from "./question"
 import { QuestionV2 } from "./question"
@@ -75,14 +73,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     const services = Layer.mergeAll(base, resources, permissionsAndTools)
     const services = Layer.mergeAll(base, resources, permissionsAndTools)
     const image = Image.layer.pipe(Layer.provide(services))
     const image = Image.layer.pipe(Layer.provide(services))
     const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
     const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
-    const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
     const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
     const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
     const todos = SessionTodo.layer.pipe(Layer.provide(services))
     const todos = SessionTodo.layer.pipe(Layer.provide(services))
     const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
     const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
     const builtInTools = BuiltInTools.locationLayer.pipe(
     const builtInTools = BuiltInTools.locationLayer.pipe(
       Layer.provide(services),
       Layer.provide(services),
       Layer.provide(mutation),
       Layer.provide(mutation),
-      Layer.provide(searches),
       Layer.provide(resources),
       Layer.provide(resources),
       Layer.provide(todos),
       Layer.provide(todos),
       Layer.provide(questions),
       Layer.provide(questions),
@@ -94,18 +90,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       Layer.provide(model),
       Layer.provide(model),
       Layer.provide(skillGuidance),
       Layer.provide(skillGuidance),
     )
     )
-    return Layer.mergeAll(
-      services,
-      image,
-      mutation,
-      searches,
-      resources,
-      todos,
-      questions,
-      model,
-      runner,
-      builtInTools,
-    ).pipe(Layer.fresh)
+    return Layer.mergeAll(services, image, mutation, resources, todos, questions, model, runner, builtInTools).pipe(
+      Layer.fresh,
+    )
   },
   },
   idleTimeToLive: "60 minutes",
   idleTimeToLive: "60 minutes",
   dependencies: [
   dependencies: [
@@ -117,6 +104,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     FSUtil.defaultLayer,
     FSUtil.defaultLayer,
     AppProcess.defaultLayer,
     AppProcess.defaultLayer,
     Global.defaultLayer,
     Global.defaultLayer,
+    Ripgrep.defaultLayer,
     Database.defaultLayer,
     Database.defaultLayer,
     SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
     SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
     PermissionSaved.defaultLayer,
     PermissionSaved.defaultLayer,
@@ -125,6 +113,5 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     FetchHttpClient.layer,
     FetchHttpClient.layer,
     ToolOutputStore.defaultCleanupLayer,
     ToolOutputStore.defaultCleanupLayer,
     ApplicationTools.layer,
     ApplicationTools.layer,
-    Search.defaultLayer,
   ],
   ],
 }) {}
 }) {}

+ 0 - 219
packages/core/src/location-search.ts

@@ -1,219 +0,0 @@
-export * as LocationSearch from "./location-search"
-
-import path from "path"
-import { Context, Effect, Layer, Option, Schema } from "effect"
-import { FSUtil } from "./fs-util"
-import { Global } from "./global"
-import { Location } from "./location"
-import { Ripgrep } from "./ripgrep"
-import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
-import { ToolOutputStore } from "./tool-output-store"
-
-/**
- * Location-scoped raw search substrate. Search authority is selected only by
- * FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
- * outside this service so future GlobTool, GrepTool, and HTTP consumers can
- * share the same bounded filesystem behavior.
- *
- * TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
- * TODO: Reuse this substrate for instruction and skill discovery where suitable.
- */
-
-export const DEFAULT_RESULT_LIMIT = 100
-export const MAX_RESULT_LIMIT = 100
-export const MAX_LINE_PREVIEW_LENGTH = 2_000
-
-export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
-
-export const FilesInput = Schema.Struct({
-  pattern: Schema.String,
-  path: Schema.String.pipe(Schema.optional),
-  limit: ResultLimit.pipe(Schema.optional),
-})
-export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
-
-export const GrepInput = Schema.Struct({
-  pattern: Schema.String,
-  include: Schema.String.pipe(Schema.optional),
-  path: Schema.String.pipe(Schema.optional),
-  limit: ResultLimit.pipe(Schema.optional),
-})
-export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
-
-export class File extends Schema.Class<File>("LocationSearch.File")({
-  path: RelativePath,
-  canonical: Schema.String,
-  resource: Schema.String,
-  mtime: Schema.Number,
-}) {}
-
-export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
-  text: Schema.String,
-  start: NonNegativeInt,
-  end: NonNegativeInt,
-}) {}
-
-export class Match extends Schema.Class<Match>("LocationSearch.Match")({
-  path: RelativePath,
-  canonical: Schema.String,
-  resource: Schema.String,
-  lines: Schema.String,
-  linePreviewTruncated: Schema.Boolean,
-  line: PositiveInt,
-  offset: NonNegativeInt,
-  submatches: Schema.Array(Submatch),
-  mtime: Schema.Number,
-}) {}
-
-export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
-  items: Schema.Array(File),
-  truncated: Schema.Boolean,
-  partial: Schema.Boolean,
-}) {}
-
-export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
-  items: Schema.Array(Match),
-  truncated: Schema.Boolean,
-  partial: Schema.Boolean,
-}) {}
-
-export interface Interface {
-  readonly files: (input: FilesInput) => Effect.Effect<FilesResult, Ripgrep.Error>
-  readonly grep: (input: GrepInput) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
-
-const slash = (value: string) => value.replaceAll("\\", "/")
-const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const fs = yield* FSUtil.Service
-    const location = yield* Location.Service
-    const global = yield* Effect.serviceOption(Global.Service)
-    const ripgrep = yield* Ripgrep.Service
-
-    const resolve = Effect.fnUntraced(function* (input?: string) {
-      const directory = input && path.isAbsolute(input) ? path.dirname(input) : location.directory
-      const absolute = path.resolve(location.directory, input ?? ".")
-      if (!path.isAbsolute(input ?? "") && !FSUtil.contains(location.directory, absolute))
-        return yield* Effect.die(new globalThis.Error("Path escapes the location"))
-      if (path.isAbsolute(input ?? "")) {
-        const managed = path.join(
-          Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
-          ToolOutputStore.MANAGED_DIRECTORY,
-        )
-        if (directory !== managed || !path.basename(absolute).startsWith("tool_"))
-          return yield* Effect.die(new globalThis.Error("Absolute path is not managed tool output"))
-      }
-      const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
-      const root = yield* fs.realPath(directory).pipe(Effect.orDie)
-      if (!FSUtil.contains(root, real)) return yield* Effect.die(new globalThis.Error("Path escapes the search root"))
-      const info = yield* fs.stat(real).pipe(Effect.orDie)
-      const type =
-        info.type === "File" ? ("file" as const) : info.type === "Directory" ? ("directory" as const) : undefined
-      if (!type) return yield* Effect.die(new globalThis.Error("Search root is not a file or directory"))
-      return { real, root, resource: slash(path.relative(root, real)) || ".", type }
-    })
-
-    const candidate = Effect.fnUntraced(function* (
-      root: { readonly real: string; readonly root: string; readonly type: "file" | "directory" },
-      cwd: string,
-      value: string,
-    ) {
-      const absolute = path.resolve(cwd, value)
-      const lexicallyContained =
-        root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
-      if (!lexicallyContained) return
-      const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
-      if (!canonical || !FSUtil.contains(root.root, canonical)) return
-      const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
-      if (!info || info.type !== "File") return
-      const relative = slash(path.relative(root.root, canonical))
-      return {
-        path: RelativePath.make(relative),
-        canonical,
-        resource: relative,
-        mtime: info.mtime.pipe(
-          Option.map((date) => date.getTime()),
-          Option.getOrElse(() => 0),
-        ),
-      }
-    })
-
-    return Service.of({
-      files: Effect.fn("LocationSearch.files")(function* (input) {
-        const root = yield* resolve(input.path)
-        if (root.type !== "directory")
-          return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
-        const result = yield* ripgrep.files({
-          cwd: root.real,
-          pattern: input.pattern,
-          limit: cap(input.limit),
-          signal: input.signal,
-        })
-        const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
-          concurrency: 16,
-        })
-        const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
-        // TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
-        // TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
-        return new FilesResult({
-          items,
-          truncated: result.truncated,
-          partial: result.partial || items.length !== result.items.length,
-        })
-      }),
-      grep: Effect.fn("LocationSearch.grep")(function* (input) {
-        const root = yield* resolve(input.path)
-        const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
-        const result = yield* ripgrep.grep({
-          cwd,
-          pattern: input.pattern,
-          include: input.include,
-          file: root.type === "file" ? path.basename(root.real) : undefined,
-          limit: cap(input.limit),
-          signal: input.signal,
-        })
-        const candidates = new Map<string, ReturnType<typeof candidate>>()
-        for (const item of result.items) {
-          if (!candidates.has(item.path.text)) {
-            candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
-          }
-        }
-        const mapped = yield* Effect.forEach(
-          result.items,
-          (item) =>
-            candidates.get(item.path.text)!.pipe(
-              Effect.map(
-                (file) =>
-                  file &&
-                  new Match({
-                    ...file,
-                    lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
-                    linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
-                    line: item.line_number,
-                    offset: item.absolute_offset,
-                    submatches: item.submatches.map(
-                      (submatch) =>
-                        new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
-                    ),
-                  }),
-              ),
-            ),
-          { concurrency: 16 },
-        )
-        const items = mapped.filter((item): item is Match => item !== undefined)
-        // TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
-        // TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
-        return new GrepResult({
-          items,
-          truncated: result.truncated,
-          partial: result.partial || items.length !== result.items.length,
-        })
-      }),
-    })
-  }),
-)

+ 2 - 3
packages/core/src/public/opencode.ts

@@ -32,7 +32,8 @@ class SessionModelValidation extends Context.Service<
   }
   }
 >()("@opencode/public/OpenCode/SessionModelValidation") {}
 >()("@opencode/public/OpenCode/SessionModelValidation") {}
 
 
-const LocationServicesLayer = LocationServiceMap.layer
+const ApplicationToolsLayer = ApplicationTools.layer
+const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
 const SessionModelValidationLayer = Layer.effect(
 const SessionModelValidationLayer = Layer.effect(
   SessionModelValidation,
   SessionModelValidation,
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -78,8 +79,6 @@ const SessionsLayer = Layer.merge(
   ),
   ),
   SessionModelValidationLayer,
   SessionModelValidationLayer,
 ).pipe(Layer.provide(LocationServicesLayer))
 ).pipe(Layer.provide(LocationServicesLayer))
-const ApplicationToolsLayer = ApplicationTools.layer
-
 // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
 // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
 export const layer = Layer.effect(
 export const layer = Layer.effect(
   Service,
   Service,

+ 120 - 28
packages/core/src/ripgrep.ts

@@ -2,20 +2,23 @@ export * as Ripgrep from "./ripgrep"
 
 
 import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
 import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { ChildProcess } from "effect/unstable/process"
-import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
+import path from "path"
+import { Entry, Match } from "./filesystem/schema"
+import { FSUtil } from "./fs-util"
 import { AppProcess, collectStream, waitForAbort } from "./process"
 import { AppProcess, collectStream, waitForAbort } from "./process"
-import { NonNegativeInt, PositiveInt } from "./schema"
+import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
+import { RipgrepBinary } from "./ripgrep/binary"
 
 
 /**
 /**
  * Small core-owned ripgrep execution adapter. It deliberately exposes raw
  * Small core-owned ripgrep execution adapter. It deliberately exposes raw
- * process-oriented rows, not model text or permission behavior. LocationSearch
- * supplies read authority and bounded substrate results; future leaf tools own
+ * process-oriented rows, not model text or permission behavior. Search maps
+ * these rows into filesystem results; leaf tools own
  * presentation and permission prompts.
  * presentation and permission prompts.
  */
  */
 
 
 const ERROR_BYTES = 8 * 1024
 const ERROR_BYTES = 8 * 1024
-export const MAX_RECORD_BYTES = 64 * 1024
-export const MAX_SUBMATCHES = 100
+const MAX_RECORD_BYTES = 64 * 1024
+const MAX_SUBMATCHES = 100
 
 
 const RawMatch = Schema.Struct({
 const RawMatch = Schema.Struct({
   type: Schema.Literal("match"),
   type: Schema.Literal("match"),
@@ -34,7 +37,7 @@ const RawMatch = Schema.Struct({
   }),
   }),
 })
 })
 
 
-export type Match = (typeof RawMatch.Type)["data"]
+type RawMatchData = (typeof RawMatch.Type)["data"]
 
 
 export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
 export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
   message: Schema.String,
   message: Schema.String,
@@ -46,16 +49,21 @@ export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternE
   message: Schema.String,
   message: Schema.String,
 }) {}
 }) {}
 
 
-export interface Result<A> {
-  readonly items: A[]
-  readonly truncated: boolean
-  readonly partial: boolean
+export interface FindInput {
+  readonly cwd: string
+  readonly pattern: string
+  readonly limit: number
+  readonly hidden?: boolean
+  readonly follow?: boolean
+  readonly signal?: AbortSignal
 }
 }
 
 
-export interface FilesInput {
+export interface GlobInput {
   readonly cwd: string
   readonly cwd: string
   readonly pattern: string
   readonly pattern: string
   readonly limit: number
   readonly limit: number
+  readonly hidden?: boolean
+  readonly follow?: boolean
   readonly signal?: AbortSignal
   readonly signal?: AbortSignal
 }
 }
 
 
@@ -69,8 +77,9 @@ export interface GrepInput {
 }
 }
 
 
 export interface Interface {
 export interface Interface {
-  readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
-  readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
+  readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
+  readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[], Error>
+  readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[], Error | InvalidPatternError>
 }
 }
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
@@ -84,7 +93,7 @@ export const layer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
     const process = yield* AppProcess.Service
     const process = yield* AppProcess.Service
-    const binary = yield* FileSystemRipgrep.Service
+    const binary = yield* RipgrepBinary.Service
 
 
     const run = <A>(input: {
     const run = <A>(input: {
       readonly cwd: string
       readonly cwd: string
@@ -137,31 +146,84 @@ export const layer = Layer.effect(
     }
     }
 
 
     return Service.of({
     return Service.of({
-      files: (input) =>
+      glob: (input) =>
         run<string>({
         run<string>({
-          ...input,
+          cwd: input.cwd,
+          limit: input.limit,
+          signal: input.signal,
           args: [
           args: [
             "--no-config",
             "--no-config",
             "--files",
             "--files",
-            "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
+            "--glob=!**/.git/**",
+            ...(input.hidden ? ["--hidden"] : []),
+            ...(input.follow ? ["--follow"] : []),
             `--glob=${input.pattern}`,
             `--glob=${input.pattern}`,
-            "--glob=!.*",
-            "--glob=!**/.*",
             ".",
             ".",
           ],
           ],
-          parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
-        }).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
+          parse: (line) =>
+            Effect.succeed(
+              line
+                .replace(/^(?:\.[\\/])+/u, "")
+                .replace(/^[\\/]+/u, "")
+                .replaceAll("\\", "/"),
+            ),
+        }).pipe(
+          Effect.map((result) =>
+            result.items.map((relative) => {
+              const absolute = path.resolve(input.cwd, relative)
+              return new Entry({
+                path: RelativePath.make(relative),
+                type: "file",
+                mime: FSUtil.mimeType(absolute),
+              })
+            }),
+          ),
+          Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
+        ),
+      find: (input) =>
+        run<string>({
+          cwd: input.cwd,
+          limit: input.limit,
+          signal: input.signal,
+          args: [
+            "--no-config",
+            "--files",
+            "--glob=!**/.git/**",
+            ...(input.hidden ? ["--hidden"] : []),
+            ...(input.follow ? ["--follow"] : []),
+            `--glob=${input.pattern}`,
+            ".",
+          ],
+          parse: (line) =>
+            Effect.succeed(
+              line
+                .replace(/^(?:\.[\\/])+/u, "")
+                .replace(/^[\\/]+/u, "")
+                .replaceAll("\\", "/"),
+            ),
+        }).pipe(
+          Effect.map((result) =>
+            result.items.map((relative) => {
+              const absolute = path.resolve(input.cwd, relative)
+              return new Entry({
+                path: RelativePath.make(relative),
+                type: "file",
+                mime: FSUtil.mimeType(absolute),
+              })
+            }),
+          ),
+          Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
+        ),
       grep: (input) =>
       grep: (input) =>
-        run<Match>({
+        run<RawMatchData>({
           ...input,
           ...input,
           args: [
           args: [
             "--no-config",
             "--no-config",
             "--json",
             "--json",
-            "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
+            "--hidden",
+            "--glob=!**/.git/**",
             "--no-messages",
             "--no-messages",
             ...(input.include ? [`--glob=${input.include}`] : []),
             ...(input.include ? [`--glob=${input.include}`] : []),
-            "--glob=!.*",
-            "--glob=!**/.*",
             "--",
             "--",
             input.pattern,
             input.pattern,
             input.file ?? ".",
             input.file ?? ".",
@@ -180,13 +242,43 @@ export const layer = Layer.effect(
                 return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
                 return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
                   Effect.map((match) => ({
                   Effect.map((match) => ({
                     ...match.data,
                     ...match.data,
+                    path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
                     submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
                     submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
                   })),
                   })),
                   Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
                   Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
                 )
                 )
               }),
               }),
             ),
             ),
-        }),
+        }).pipe(
+          Effect.map((result) =>
+            result.items.map((match) => {
+              const relative = match.path.text
+                .replace(/^(?:\.[\\/])+/u, "")
+                .replace(/^[\\/]+/u, "")
+                .replaceAll("\\", "/")
+              const absolute = path.resolve(input.cwd, relative)
+              return new Match({
+                entry: new Entry({
+                  path: RelativePath.make(relative),
+                  type: "file",
+                  mime: FSUtil.mimeType(absolute),
+                }),
+                line: match.line_number,
+                offset: match.absolute_offset,
+                text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
+                submatches: match.submatches.map((submatch) => ({
+                  text: submatch.match.text,
+                  start: submatch.start,
+                  end: submatch.end,
+                })),
+              })
+            }),
+          ),
+        ),
     })
     })
   }),
   }),
-).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)),
+)

+ 124 - 0
packages/core/src/ripgrep/binary.ts

@@ -0,0 +1,124 @@
+import path from "path"
+import { Context, Effect, Layer, Stream } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import { CrossSpawnSpawner } from "../cross-spawn-spawner"
+import { FSUtil } from "../fs-util"
+import { Global } from "../global"
+import { which } from "../util/which"
+
+export namespace RipgrepBinary {
+  const VERSION = "15.1.0"
+  const PLATFORM = {
+    "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
+    "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
+    "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
+    "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
+    "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
+    "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
+    "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
+  } as const
+
+  interface Interface {
+    readonly filepath: Effect.Effect<string, Error>
+  }
+
+  export class Service extends Context.Service<Service, Interface>()("@opencode/RipgrepBinary") {}
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* FSUtil.Service
+      const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
+      const spawner = yield* ChildProcessSpawner
+
+      const run = Effect.fnUntraced(function* (command: string, args: string[]) {
+        const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
+        const [stdout, stderr, code] = yield* Effect.all(
+          [
+            Stream.mkString(Stream.decodeText(handle.stdout)),
+            Stream.mkString(Stream.decodeText(handle.stderr)),
+            handle.exitCode,
+          ],
+          { concurrency: "unbounded" },
+        )
+        return { stdout, stderr, code }
+      }, Effect.scoped)
+
+      const extract = Effect.fnUntraced(function* (
+        archive: string,
+        config: (typeof PLATFORM)[keyof typeof PLATFORM],
+        target: string,
+      ) {
+        const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
+
+        if (config.extension === "zip") {
+          const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
+          const result = yield* run(shell, [
+            "-NoProfile",
+            "-NonInteractive",
+            "-Command",
+            `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
+          ])
+          if (result.code !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`)
+        }
+
+        if (config.extension === "tar.gz") {
+          const result = yield* run("tar", ["-xzf", archive, "-C", dir])
+          if (result.code !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`)
+        }
+
+        const extracted = path.join(
+          dir,
+          `ripgrep-${VERSION}-${config.platform}`,
+          process.platform === "win32" ? "rg.exe" : "rg",
+        )
+        if (!(yield* fs.isFile(extracted))) throw new Error(`ripgrep archive did not contain executable: ${extracted}`)
+
+        yield* fs.copyFile(extracted, target)
+        if (process.platform !== "win32") yield* fs.chmod(target, 0o755)
+      }, Effect.scoped)
+
+      return Service.of({
+        filepath: yield* Effect.cached(
+          Effect.gen(function* () {
+            const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
+            if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
+
+            const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
+            if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
+
+            const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
+            const config = PLATFORM[platformKey]
+            if (!config) throw new Error(`unsupported platform for ripgrep: ${platformKey}`)
+
+            const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
+            const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
+            const archive = path.join(Global.Path.bin, filename)
+
+            yield* Effect.logInfo("downloading ripgrep", { url })
+            yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
+            const bytes = yield* HttpClientRequest.get(url).pipe(
+              http.execute,
+              Effect.flatMap((response) => response.arrayBuffer),
+              Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
+            )
+            if (bytes.byteLength === 0) throw new Error(`failed to download ripgrep from ${url}`)
+
+            yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
+            yield* extract(archive, config, target)
+            yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
+            return target
+          }),
+        ),
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(
+    Layer.provide(FetchHttpClient.layer),
+    Layer.provide(FSUtil.defaultLayer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
+  )
+}

+ 39 - 23
packages/core/src/tool/glob.ts

@@ -2,7 +2,11 @@ export * as GlobTool from "./glob"
 
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
-import { LocationSearch } from "../location-search"
+import path from "path"
+import { FileSystem } from "../filesystem"
+import { Location } from "../location"
+import { Ripgrep } from "../ripgrep"
+import { RelativePath } from "../schema"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 import { Tools } from "./tools"
@@ -10,38 +14,30 @@ import { Tools } from "./tools"
 export const name = "glob"
 export const name = "glob"
 
 
 export const Input = Schema.Struct({
 export const Input = Schema.Struct({
-  pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
-  path: LocationSearch.FilesInput.fields.path.annotate({
+  pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
+  path: RelativePath.pipe(Schema.optional).annotate({
     description: "Relative directory to search. Defaults to the active Location.",
     description: "Relative directory to search. Defaults to the active Location.",
   }),
   }),
-  limit: LocationSearch.FilesInput.fields.limit.annotate({
-    description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
+  limit: FileSystem.GlobInput.fields.limit.annotate({
+    description: "Maximum results to return",
   }),
   }),
 })
 })
 
 
-type ModelOutput = typeof LocationSearch.FilesResult.Encoded
+export const Output = Schema.Array(FileSystem.Entry)
+type ModelOutput = typeof Output.Encoded
 
 
-/** Format raw Location search results into the concise line-oriented output models expect. */
+/** Format raw search results into the concise line-oriented output models expect. */
 export const toModelOutput = (output: ModelOutput) => {
 export const toModelOutput = (output: ModelOutput) => {
-  const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
-  if (output.truncated) {
-    lines.push(
-      "",
-      `(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`,
-    )
-  }
-  if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
+  const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
   return lines.join("\n")
   return lines.join("\n")
 }
 }
 
 
-/**
- * Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
- * LocationSearch resolves the current root and owns containment and traversal.
- */
+/** Glob leaf that defaults its filesystem root to the active Location. */
 export const layer = Layer.effectDiscard(
 export const layer = Layer.effectDiscard(
   Effect.gen(function* () {
   Effect.gen(function* () {
     const tools = yield* Tools.Service
     const tools = yield* Tools.Service
-    const search = yield* LocationSearch.Service
+    const ripgrep = yield* Ripgrep.Service
+    const location = yield* Location.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
     yield* tools
     yield* tools
@@ -50,8 +46,13 @@ export const layer = Layer.effectDiscard(
           description:
           description:
             "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
             "Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
           input: Input,
           input: Input,
-          output: LocationSearch.FilesResult,
-          toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
+          output: Output,
+          toModelOutput: ({ output }) => [
+            {
+              type: "text",
+              text: toModelOutput(output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) }))),
+            },
+          ],
           execute: (input, context) =>
           execute: (input, context) =>
             Effect.gen(function* () {
             Effect.gen(function* () {
               yield* permission.assert({
               yield* permission.assert({
@@ -67,7 +68,22 @@ export const layer = Layer.effectDiscard(
                 agent: context.agent,
                 agent: context.agent,
                 source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
                 source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
               })
               })
-              return yield* search.files(input)
+              const cwd = path.resolve(location.directory, input.path ?? ".")
+              return yield* ripgrep.glob({
+                cwd,
+                pattern: input.pattern,
+                limit: input.limit ?? Number.MAX_SAFE_INTEGER,
+              }).pipe(
+                Effect.map((result) =>
+                  result.map(
+                    (entry) =>
+                      new FileSystem.Entry({
+                        ...entry,
+                        path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
+                      }),
+                  ),
+                ),
+              )
             }).pipe(
             }).pipe(
               Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
               Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
             ),
             ),

+ 67 - 40
packages/core/src/tool/grep.ts

@@ -2,61 +2,58 @@ export * as GrepTool from "./grep"
 
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
-import { LocationSearch } from "../location-search"
-import { Ripgrep } from "../ripgrep"
+import path from "path"
+import { FileSystem } from "../filesystem"
+import { FSUtil } from "../fs-util"
+import { Location } from "../location"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
+import { Ripgrep } from "../ripgrep"
+import { RelativePath } from "../schema"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
 import { Tools } from "./tools"
 
 
 export const name = "grep"
 export const name = "grep"
 
 
 export const Input = Schema.Struct({
 export const Input = Schema.Struct({
-  pattern: LocationSearch.GrepInput.fields.pattern.annotate({
+  pattern: FileSystem.GrepInput.fields.pattern.annotate({
     description: "Regex pattern to search for in file contents",
     description: "Regex pattern to search for in file contents",
   }),
   }),
-  path: LocationSearch.GrepInput.fields.path.annotate({
-    description: "Relative file or directory to search. Defaults to the active Location.",
+  path: RelativePath.pipe(Schema.optional).annotate({
+    description: "Relative directory to search. Defaults to the active Location.",
   }),
   }),
-  include: LocationSearch.GrepInput.fields.include.annotate({
+  include: FileSystem.GrepInput.fields.include.annotate({
     description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
     description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
   }),
   }),
-  limit: LocationSearch.GrepInput.fields.limit.annotate({
-    description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
+  limit: FileSystem.GrepInput.fields.limit.annotate({
+    description: "Maximum matches to return",
   }),
   }),
 })
 })
 
 
-type Output = typeof LocationSearch.GrepResult.Encoded
+export const Output = Schema.Array(FileSystem.Match)
+type ModelOutput = typeof Output.Encoded
 
 
-/** Format raw Location search matches into the familiar concise model output. */
-export const toModelOutput = (output: Output) => {
-  const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
+/** Format raw search matches into the familiar concise model output. */
+export const toModelOutput = (output: ModelOutput) => {
+  const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
   let current = ""
   let current = ""
-  for (const match of output.items) {
-    if (current !== match.resource) {
+  for (const match of output) {
+    if (current !== match.entry.path) {
       if (current) lines.push("")
       if (current) lines.push("")
-      current = match.resource
-      lines.push(`${match.resource}:`)
+      current = match.entry.path
+      lines.push(`${match.entry.path}:`)
     }
     }
-    lines.push(`  Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
-  }
-  if (output.truncated) {
-    lines.push(
-      "",
-      `(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`,
-    )
+    lines.push(`  Line ${match.line}: ${match.text}`)
   }
   }
-  if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
   return lines.join("\n")
   return lines.join("\n")
 }
 }
 
 
-/**
- * Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
- * LocationSearch resolves the current root and owns containment and ripgrep execution.
- */
+/** Grep leaf that defaults its filesystem root to the active Location. */
 export const layer = Layer.effectDiscard(
 export const layer = Layer.effectDiscard(
   Effect.gen(function* () {
   Effect.gen(function* () {
     const tools = yield* Tools.Service
     const tools = yield* Tools.Service
-    const search = yield* LocationSearch.Service
+    const fs = yield* FSUtil.Service
+    const ripgrep = yield* Ripgrep.Service
+    const location = yield* Location.Service
     const permission = yield* PermissionV2.Service
     const permission = yield* PermissionV2.Service
 
 
     yield* tools
     yield* tools
@@ -65,8 +62,18 @@ export const layer = Layer.effectDiscard(
           description:
           description:
             "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
             "Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
           input: Input,
           input: Input,
-          output: LocationSearch.GrepResult,
-          toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
+          output: Output,
+          toModelOutput: ({ output }) => [
+            {
+              type: "text",
+              text: toModelOutput(
+                output.map((match) => ({
+                  ...match,
+                  entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
+                })),
+              ),
+            },
+          ],
           execute: (input, context) =>
           execute: (input, context) =>
             Effect.gen(function* () {
             Effect.gen(function* () {
               yield* permission.assert({
               yield* permission.assert({
@@ -74,7 +81,7 @@ export const layer = Layer.effectDiscard(
                 resources: [input.pattern],
                 resources: [input.pattern],
                 save: ["*"],
                 save: ["*"],
                 metadata: {
                 metadata: {
-                  root: input.path ?? ".",
+                  root: ".",
                   path: input.path,
                   path: input.path,
                   include: input.include,
                   include: input.include,
                   limit: input.limit,
                   limit: input.limit,
@@ -83,15 +90,35 @@ export const layer = Layer.effectDiscard(
                 agent: context.agent,
                 agent: context.agent,
                 source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
                 source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
               })
               })
-              return yield* search.grep(input)
+              const target = path.resolve(location.directory, input.path ?? ".")
+              const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
+              return yield* ripgrep.grep({
+                cwd: info?.type === "Directory" ? target : path.dirname(target),
+                pattern: input.pattern,
+                file: info?.type === "File" ? path.basename(target) : undefined,
+                include: input.include,
+                limit: input.limit ?? Number.MAX_SAFE_INTEGER,
+              }).pipe(
+                Effect.map((result) =>
+                  result.map(
+                    (match) =>
+                      new FileSystem.Match({
+                        ...match,
+                        entry: new FileSystem.Entry({
+                          ...match.entry,
+                          path: RelativePath.make(
+                            path.relative(
+                              location.directory,
+                              path.resolve(info?.type === "Directory" ? target : path.dirname(target), match.entry.path),
+                            ),
+                          ),
+                        }),
+                      }),
+                  ),
+                ),
+              )
             }).pipe(
             }).pipe(
-              Effect.mapError((error) => {
-                const message =
-                  error instanceof Ripgrep.InvalidPatternError
-                    ? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
-                    : `Unable to grep for ${input.pattern}`
-                return new ToolFailure({ message })
-              }),
+              Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` })),
             ),
             ),
         }),
         }),
       })
       })

+ 1 - 2
packages/core/src/tool/read-filesystem.ts

@@ -276,8 +276,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
         const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
         const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
         if (!type) return
         if (!type) return
         return new FileSystem.Entry({
         return new FileSystem.Entry({
-          path: RelativePath.make(item.name),
-          uri: pathToFileURL(target).href,
+          path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
           type,
           type,
           mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
           mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
         })
         })

+ 0 - 231
packages/core/test/filesystem/ripgrep.test.ts

@@ -1,231 +0,0 @@
-import { describe, expect } from "bun:test"
-import { Effect } from "effect"
-import * as Stream from "effect/Stream"
-import fs from "fs/promises"
-import os from "os"
-import path from "path"
-import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
-import { testEffect } from "../lib/effect"
-
-const it = testEffect(Ripgrep.defaultLayer)
-
-const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
-  Effect.acquireRelease(
-    Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
-    (dir) =>
-      Effect.promise(() =>
-        fs.rm(dir, {
-          recursive: true,
-          force: true,
-          maxRetries: 5,
-          retryDelay: 100,
-        }),
-      ).pipe(Effect.ignore),
-  ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
-
-const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
-const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
-const collectFiles = (input: Ripgrep.FilesInput) =>
-  Ripgrep.Service.use((rg) =>
-    rg.files(input).pipe(
-      Stream.runCollect,
-      Effect.map((c) => [...c]),
-    ),
-  )
-
-const withRipgrepConfig = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) =>
-  Effect.acquireUseRelease(
-    Effect.sync(() => {
-      const prev = process.env["RIPGREP_CONFIG_PATH"]
-      process.env["RIPGREP_CONFIG_PATH"] = value
-      return prev
-    }),
-    () => effect,
-    (prev) =>
-      Effect.sync(() => {
-        if (prev === undefined) delete process.env["RIPGREP_CONFIG_PATH"]
-        else process.env["RIPGREP_CONFIG_PATH"] = prev
-      }),
-  )
-
-describe("file.ripgrep", () => {
-  it.live("exposes a cached managed executable filepath", () =>
-    Effect.gen(function* () {
-      const ripgrep = yield* Ripgrep.Service
-      const first = yield* ripgrep.filepath
-      const second = yield* ripgrep.filepath
-
-      expect(first).toBe(second)
-      expect((yield* Effect.promise(() => fs.stat(first))).isFile()).toBe(true)
-    }),
-  )
-
-  it.live("defaults to include hidden", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* write(path.join(dir, "visible.txt"), "hello")
-          yield* mkdir(path.join(dir, ".opencode"))
-          yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
-        }),
-      )
-
-      const files = yield* collectFiles({ cwd: dir })
-      expect(files.includes("visible.txt")).toBe(true)
-      expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
-    }),
-  )
-
-  it.live("hidden false excludes hidden", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* write(path.join(dir, "visible.txt"), "hello")
-          yield* mkdir(path.join(dir, ".opencode"))
-          yield* write(path.join(dir, ".opencode", "thing.json"), "{}")
-        }),
-      )
-
-      const files = yield* collectFiles({ cwd: dir, hidden: false })
-      expect(files.includes("visible.txt")).toBe(true)
-      expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
-    }),
-  )
-
-  it.live("search returns empty when nothing matches", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const value = 'other'\n"))
-
-      const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
-      expect(result.partial).toBe(false)
-      expect(result.items).toEqual([])
-    }),
-  )
-
-  it.live("search returns match metadata with normalized path", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* mkdir(path.join(dir, "src"))
-          yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
-        }),
-      )
-
-      const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle" })
-      expect(result.partial).toBe(false)
-      expect(result.items).toHaveLength(1)
-      expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
-      expect(result.items[0]?.line_number).toBe(1)
-      expect(result.items[0]?.lines.text).toContain("needle")
-    }),
-  )
-
-  it.live("search returns matched rows with glob filter", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
-          yield* write(path.join(dir, "skip.txt"), "const value = 'other'\n")
-        }),
-      )
-
-      const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", glob: ["*.ts"] })
-      expect(result.partial).toBe(false)
-      expect(result.items).toHaveLength(1)
-      expect(result.items[0]?.path.text).toContain("match.ts")
-      expect(result.items[0]?.lines.text).toContain("needle")
-    }),
-  )
-
-  it.live("search supports explicit file targets", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* write(path.join(dir, "match.ts"), "const value = 'needle'\n")
-          yield* write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
-        }),
-      )
-
-      const file = path.join(dir, "match.ts")
-      const result = yield* Ripgrep.use.search({ cwd: dir, pattern: "needle", file: [file] })
-      expect(result.partial).toBe(false)
-      expect(result.items).toHaveLength(1)
-      expect(result.items[0]?.path.text).toBe(file)
-    }),
-  )
-
-  it.live("files returns empty when glob matches no files", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* mkdir(path.join(dir, "packages", "console"))
-          yield* write(path.join(dir, "packages", "console", "package.json"), "{}")
-        }),
-      )
-
-      const files = yield* collectFiles({ cwd: dir, glob: ["packages/*"] })
-      expect(files).toEqual([])
-    }),
-  )
-
-  it.live("files returns stream of filenames", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* write(path.join(dir, "a.txt"), "hello")
-          yield* write(path.join(dir, "b.txt"), "world")
-        }),
-      )
-
-      const files = yield* collectFiles({ cwd: dir }).pipe(Effect.map((files) => files.sort()))
-      expect(files).toEqual(["a.txt", "b.txt"])
-    }),
-  )
-
-  it.live("files respects glob filter", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) =>
-        Effect.gen(function* () {
-          yield* write(path.join(dir, "keep.ts"), "yes")
-          yield* write(path.join(dir, "skip.txt"), "no")
-        }),
-      )
-
-      const files = yield* collectFiles({ cwd: dir, glob: ["*.ts"] })
-      expect(files).toEqual(["keep.ts"])
-    }),
-  )
-
-  it.live("files dies on nonexistent directory", () =>
-    Effect.gen(function* () {
-      const exit = yield* Ripgrep.Service.use((rg) =>
-        rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect),
-      ).pipe(Effect.exit)
-      expect(exit._tag).toBe("Failure")
-    }),
-  )
-
-  it.live("ignores RIPGREP_CONFIG_PATH in direct mode", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
-
-      const result = yield* withRipgrepConfig(
-        path.join(dir, "missing-ripgreprc"),
-        Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
-      )
-      expect(result.items).toHaveLength(1)
-    }),
-  )
-
-  it.live("ignores RIPGREP_CONFIG_PATH in worker mode", () =>
-    Effect.gen(function* () {
-      const dir = yield* tmpdir((dir) => write(path.join(dir, "match.ts"), "const needle = 1\n"))
-
-      const result = yield* withRipgrepConfig(
-        path.join(dir, "missing-ripgreprc"),
-        Ripgrep.use.search({ cwd: dir, pattern: "needle" }),
-      )
-      expect(result.items).toHaveLength(1)
-    }),
-  )
-})

+ 33 - 165
packages/core/test/filesystem/search.test.ts

@@ -1,176 +1,44 @@
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
 import fs from "fs/promises"
 import fs from "fs/promises"
-import os from "os"
 import path from "path"
 import path from "path"
 import { Effect } from "effect"
 import { Effect } from "effect"
-import { Fff } from "#fff"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
+import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
+import { tmpdir } from "../fixture/tmpdir"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 
 
-const it = testEffect(Search.defaultLayer)
+const it = testEffect(Ripgrep.defaultLayer)
 
 
-const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
+const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
   Effect.acquireRelease(
   Effect.acquireRelease(
-    Effect.promise(async () => fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")))),
-    (dir) =>
-      Effect.promise(() => fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })).pipe(
-        Effect.ignore,
-      ),
-  ).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
-
-const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
-const waitForFileIndex = (search: Search.Interface, cwd: string) =>
-  search.glob({ cwd, pattern: "**/*", limit: 1 }).pipe(Effect.ignore)
-
-describe("file.search", () => {
-  it.live("uses fff for Bun-backed grep", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
-
-      const search = yield* Search.Service
-      const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
-
-      expect(result.engine).toBe("fff")
-      expect(result.items).toHaveLength(1)
-      expect(result.items[0]?.path.text).toBe("src/match.ts")
-    }),
-  )
-
-  it.live("keeps fuzzy file abbreviation matches", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "README.md"), "hello\n")
-
-      const search = yield* Search.Service
-      yield* waitForFileIndex(search, dir)
-      const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
-
-      expect(results).toContainEqual({ path: "README.md", type: "file" })
-    }),
-  )
-
-  it.live("keeps empty file query candidates", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "README.md"), "hello\n")
-      yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
-
-      const search = yield* Search.Service
-      yield* waitForFileIndex(search, dir)
-      const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
-
-      expect(results).toContainEqual({ path: "README.md", type: "file" })
-      expect(results).toContainEqual({ path: "src/", type: "directory" })
-      expect(results.map((item) => item.path)).not.toContain("")
-    }),
-  )
-
-  it.live("stabilizes equal score file candidates by path length", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "src", "longer-name.ts"), "export const longer = true\n")
-      yield* write(path.join(dir, "a.ts"), "export const shorter = true\n")
-
-      const search = yield* Search.Service
-      yield* waitForFileIndex(search, dir)
-      const results = yield* search.file({ cwd: dir, query: "", limit: 10 })
-
-      expect(results.slice(0, 2)).toEqual([
-        { path: "a.ts", type: "file" },
-        { path: "src/longer-name.ts", type: "file" },
-      ])
-    }),
-  )
-
-  it.live("keeps paging grep results without an explicit limit", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "matches.txt"), Array.from({ length: 150 }, (_, idx) => `needle ${idx}\n`).join(""))
-
-      const search = yield* Search.Service
-      const result = yield* search.search({ cwd: dir, pattern: "needle" })
-
-      expect(result.items).toHaveLength(150)
-    }),
+    Effect.promise(() => tmpdir()),
+    (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+  ).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
+
+describe("Ripgrep", () => {
+  it.live("globs files as an array", () =>
+    withTmp((cwd) =>
+      Effect.gen(function* () {
+        yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
+        yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
+        const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
+        expect(result.map((item) => item.path)).toEqual([RelativePath.make(path.join("src", "match.ts"))])
+      }),
+    ),
+  )
+
+  it.live("greps files with include filtering", () =>
+    withTmp((cwd) =>
+      Effect.gen(function* () {
+        yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
+        yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
+        yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
+        const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
+        expect(result).toHaveLength(1)
+        expect(result[0]?.entry.path).toBe(RelativePath.make(path.join("src", "match.ts")))
+        expect(result[0]?.submatches[0]?.text).toBe("needle")
+      }),
+    ),
   )
   )
 
 
-  it.live("uses byte ranges for UTF-8 grep submatches", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "unicode.txt"), "éneedle\n")
-
-      const search = yield* Search.Service
-      const result = yield* search.search({ cwd: dir, pattern: "needle", limit: 10 })
-
-      expect(result.items[0]?.submatches[0]?.match.text).toBe("needle")
-    }),
-  )
-
-  it.live("post-filters fff grep include matches", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "src", "match.ts"), "needle\n")
-      yield* write(path.join(dir, "src", "match.txt"), "needle\n")
-
-      const search = yield* Search.Service
-      const result = yield* search.search({ cwd: dir, pattern: "needle", glob: ["*.ts"], limit: 10 })
-
-      expect(result.engine).toBe("fff")
-      expect(result.items.map((entry) => entry.path.text)).toEqual(["src/match.ts"])
-    }),
-  )
-
-  it.live("keeps fff grep include no-match results", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "src", "match.ts"), "needle\n")
-
-      const search = yield* Search.Service
-      const result = yield* search.search({ cwd: dir, pattern: "missing", glob: ["*.ts"], limit: 10 })
-
-      expect(result.engine).toBe("fff")
-      expect(result.items).toEqual([])
-    }),
-  )
-
-  it.live("post-filters fff glob matches", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "src", "match.ts"), "export const value = 1\n")
-      yield* write(path.join(dir, "src", "match.txt"), "hello\n")
-
-      const search = yield* Search.Service
-      const result = yield* search.glob({ cwd: dir, pattern: "**/*.ts", limit: 10 })
-
-      expect(result.files).toEqual([path.join(dir, "src", "match.ts")])
-    }),
-  )
-
-  it.live("tracks an opened file against its originating query", () =>
-    Effect.gen(function* () {
-      expect(Fff.available()).toBe(true)
-      const dir = yield* tmpdir()
-      yield* write(path.join(dir, "alpha-target-one.ts"), "export const one = 1\n")
-      yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
-
-      const search = yield* Search.Service
-      yield* waitForFileIndex(search, dir)
-      const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
-      expect(results).toContainEqual({ path: "alpha-target-two.ts", type: "file" })
-
-      // open() records the query->file association in fff's history db via the
-      // live picker. It must resolve a remembered file and run without error.
-      yield* search.open({ cwd: dir, file: "alpha-target-two.ts" })
-    }),
-  )
 })
 })

+ 22 - 110
packages/core/test/location-filesystem.test.ts

@@ -4,159 +4,71 @@ import { fileURLToPath } from "url"
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Layer } from "effect"
 import { Effect, Exit, Layer } from "effect"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FileSystem } from "@opencode-ai/core/filesystem"
-import { Search } from "@opencode-ai/core/filesystem/search"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { location } from "./fixture/location"
 import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { tmpdir } from "./fixture/tmpdir"
 import { it } from "./lib/effect"
 import { it } from "./lib/effect"
 
 
-function provide(directory: string, search = Search.defaultLayer) {
-  return Effect.provide(
+const provide = (directory: string) =>
+  Effect.provide(
     FileSystem.layer.pipe(
     FileSystem.layer.pipe(
       Layer.provide(
       Layer.provide(
         Layer.mergeAll(
         Layer.mergeAll(
           FSUtil.defaultLayer,
           FSUtil.defaultLayer,
-          search,
+          Ripgrep.defaultLayer,
           Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
           Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
         ),
         ),
       ),
       ),
     ),
     ),
   )
   )
-}
 
 
-function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
-  return Effect.acquireRelease(
+const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+  Effect.acquireRelease(
     Effect.promise(() => tmpdir()),
     Effect.promise(() => tmpdir()),
     (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
     (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
   ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
   ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
-}
 
 
 describe("FileSystem", () => {
 describe("FileSystem", () => {
-  it.live("reads complete text and binary files", () =>
+  it.live("reads text and binary files", () =>
     withTmp((directory) =>
     withTmp((directory) =>
       Effect.gen(function* () {
       Effect.gen(function* () {
-        const text = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join("\n")
-        yield* Effect.promise(() => fs.writeFile(path.join(directory, "large.txt"), text))
+        yield* Effect.promise(() => fs.writeFile(path.join(directory, "text.txt"), "hello"))
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
         const service = yield* FileSystem.Service
         const service = yield* FileSystem.Service
-        const textContent = yield* service.read({ path: RelativePath.make("large.txt") })
-        expect(textContent).toEqual({
-          uri: textContent.uri,
-          name: "large.txt",
-          content: text,
-          encoding: "utf8",
-          mime: "text/plain",
-        })
-        expect(fileURLToPath(textContent.uri)).toBe(path.join(directory, "large.txt"))
-        const binaryContent = yield* service.read({ path: RelativePath.make("data.bin") })
-        expect(binaryContent).toEqual({
-          uri: binaryContent.uri,
-          name: "data.bin",
-          content: "AAEC",
-          encoding: "base64",
-          mime: "application/octet-stream",
-        })
-        expect(fileURLToPath(binaryContent.uri)).toBe(path.join(directory, "data.bin"))
+        const text = yield* service.read({ path: RelativePath.make("text.txt") })
+        const binary = yield* service.read({ path: RelativePath.make("data.bin") })
+        expect(text).toMatchObject({ name: "text.txt", content: "hello", encoding: "utf8", mime: "text/plain" })
+        expect(fileURLToPath(text.uri)).toBe(path.join(directory, "text.txt"))
+        expect(binary).toMatchObject({ name: "data.bin", content: "AAEC", encoding: "base64" })
       }).pipe(provide(directory)),
       }).pipe(provide(directory)),
     ),
     ),
   )
   )
 
 
-  it.live("lists direct children with relative paths and resolved URIs", () =>
+  it.live("lists direct children", () =>
     withTmp((directory) =>
     withTmp((directory) =>
       Effect.gen(function* () {
       Effect.gen(function* () {
         yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
         yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
         const entries = yield* (yield* FileSystem.Service).list()
         const entries = yield* (yield* FileSystem.Service).list()
-        expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
-          { path: RelativePath.make("src"), type: "directory", mime: "application/x-directory" },
-          { path: RelativePath.make("README.md"), type: "file", mime: "text/markdown" },
+        expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
+          { path: RelativePath.make("src" + path.sep), type: "directory" },
+          { path: RelativePath.make("README.md"), type: "file" },
         ])
         ])
-        expect(
-          yield* Effect.promise(() => Promise.all(entries.map((entry) => fs.realpath(fileURLToPath(entry.uri))))),
-        ).toEqual(
-          yield* Effect.promise(() =>
-            Promise.all([fs.realpath(path.join(directory, "src")), fs.realpath(path.join(directory, "README.md"))]),
-          ),
-        )
       }).pipe(provide(directory)),
       }).pipe(provide(directory)),
     ),
     ),
   )
   )
 
 
-  it.live("rejects lexical and symlink escapes", () =>
+  it.live("rejects lexical escapes", () =>
     withTmp((directory) =>
     withTmp((directory) =>
       Effect.gen(function* () {
       Effect.gen(function* () {
-        const service = yield* FileSystem.Service
-        expect(
-          Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
-        ).toBe(true)
-        if (process.platform === "win32") return
-        const outside = `${directory}-outside.txt`
-        yield* Effect.promise(() => fs.writeFile(outside, "outside"))
-        yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "link.txt")))
-        expect(Exit.isFailure(yield* service.read({ path: RelativePath.make("link.txt") }).pipe(Effect.exit))).toBe(
-          true,
-        )
-        yield* Effect.promise(() => fs.rm(outside, { force: true }))
+        const result = yield* (yield* FileSystem.Service)
+          .read({ path: RelativePath.make("../outside.txt") })
+          .pipe(Effect.exit)
+        expect(Exit.isFailure(result)).toBe(true)
       }).pipe(provide(directory)),
       }).pipe(provide(directory)),
     ),
     ),
   )
   )
-
-  it.live("finds and greps files", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
-        yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
-        const service = yield* FileSystem.Service
-        expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
-          RelativePath.make(path.join("src", "index.ts")),
-        ])
-        expect(yield* service.grep({ pattern: "needle" })).toMatchObject([
-          { path: RelativePath.make(path.join("src", "index.ts")), line: 1, offset: 0 },
-        ])
-      }).pipe(
-        provide(
-          directory,
-          Layer.effect(
-            Search.Service,
-            Effect.gen(function* () {
-              const search = yield* Search.Service
-              return Search.Service.of({
-                ...search,
-                file: () => Effect.succeed([{ path: path.join("src", "index.ts"), type: "file" }]),
-              })
-            }),
-          ).pipe(Layer.provide(Search.defaultLayer)),
-        ),
-      ),
-    ),
-  )
-
-  it.live("uses the type supplied by Search file results", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(() => fs.writeFile(path.join(directory, "selected.ts"), "export {}\n"))
-        expect((yield* (yield* FileSystem.Service).find({ query: "ignored", limit: 1 }))[0]).toMatchObject({
-          path: RelativePath.make("selected.ts"),
-          type: "directory",
-          mime: "application/x-directory",
-        })
-      }).pipe(
-        provide(
-          directory,
-          Layer.effect(
-            Search.Service,
-            Effect.gen(function* () {
-              const search = yield* Search.Service
-              return Search.Service.of({
-                ...search,
-                file: () => Effect.succeed([{ path: "selected.ts", type: "directory" }]),
-              })
-            }),
-          ).pipe(Layer.provide(Search.defaultLayer)),
-        ),
-      ),
-    ),
-  )
 })
 })

+ 1 - 2
packages/core/test/location-layer.test.ts

@@ -19,7 +19,6 @@ import { ModelsDev } from "../src/models-dev"
 import { Npm } from "../src/npm"
 import { Npm } from "../src/npm"
 import { Project } from "../src/project"
 import { Project } from "../src/project"
 import { Reference } from "../src/reference"
 import { Reference } from "../src/reference"
-import { LocationSearch } from "../src/location-search"
 import { ToolRegistry } from "../src/tool/registry"
 import { ToolRegistry } from "../src/tool/registry"
 import { ApplicationTools } from "../src/tool/application-tools"
 import { ApplicationTools } from "../src/tool/application-tools"
 
 
@@ -28,6 +27,7 @@ const it = testEffect(
   Layer.merge(
   Layer.merge(
     applicationTools,
     applicationTools,
     LocationServiceMap.layer.pipe(
     LocationServiceMap.layer.pipe(
+      Layer.provide(applicationTools),
       Layer.provide(
       Layer.provide(
         Layer.mergeAll(
         Layer.mergeAll(
           Project.defaultLayer,
           Project.defaultLayer,
@@ -72,7 +72,6 @@ describe("LocationServiceMap", () => {
             Effect.gen(function* () {
             Effect.gen(function* () {
               yield* PluginBoot.Service.use((boot) => boot.wait())
               yield* PluginBoot.Service.use((boot) => boot.wait())
               yield* Reference.Service
               yield* Reference.Service
-              yield* LocationSearch.Service
               const catalog = yield* Catalog.Service
               const catalog = yield* Catalog.Service
               const transform = yield* catalog.transform()
               const transform = yield* catalog.transform()
               yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
               yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))

+ 0 - 260
packages/core/test/location-search.test.ts

@@ -1,260 +0,0 @@
-import fs from "fs/promises"
-import path from "path"
-import { describe, expect, test } from "bun:test"
-import { Cause, Effect, Exit, Layer, Schema } from "effect"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Location } from "@opencode-ai/core/location"
-import { FileSystem } from "@opencode-ai/core/filesystem"
-import { Search } from "@opencode-ai/core/filesystem/search"
-import { LocationSearch } from "@opencode-ai/core/location-search"
-import { AppProcess } from "@opencode-ai/core/process"
-import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
-import { Ripgrep } from "@opencode-ai/core/ripgrep"
-import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
-import { Global } from "@opencode-ai/core/global"
-import { tmpdir } from "./fixture/tmpdir"
-import { location } from "./fixture/location"
-import { it } from "./lib/effect"
-
-function provide(directory: string, data = Global.Path.data) {
-  const dependencies = Layer.mergeAll(
-    FSUtil.defaultLayer,
-    FileSystemRipgrep.defaultLayer,
-    Search.defaultLayer,
-    AppProcess.defaultLayer,
-    Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
-    Global.layerWith({ data }),
-  )
-  const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
-  const search = LocationSearch.layer.pipe(
-    Layer.provide(filesystem),
-    Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(dependencies),
-  )
-  return Effect.provide(Layer.merge(filesystem, search))
-}
-
-function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
-  return Effect.acquireRelease(
-    Effect.promise(() => tmpdir()),
-    (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
-  ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
-}
-
-describe("LocationSearch", () => {
-  it.live("greps an absolute managed tool-output file", () =>
-    withTmp((directory) => {
-      const data = path.join(directory, "data")
-      const managed = path.join(data, "tool-output")
-      const output = path.join(managed, "tool_123")
-      return Effect.gen(function* () {
-        yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
-        yield* Effect.promise(() => fs.writeFile(output, "ok\nFAIL here\nok"))
-        const search = yield* LocationSearch.Service
-        const result = yield* search.grep({ pattern: "FAIL", path: output })
-        expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
-      }).pipe(provide(directory, data))
-    }),
-  )
-
-  it.live("searches files in the active Location with structured bounded results", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(async () => {
-          await fs.mkdir(path.join(directory, "src"))
-          await fs.writeFile(path.join(directory, "src", "index.ts"), "export const value = 1\n")
-          await fs.writeFile(path.join(directory, "notes.txt"), "notes\n")
-        })
-        const result = yield* (yield* LocationSearch.Service).files({ pattern: "*.ts" })
-        const canonical = yield* Effect.promise(() => fs.realpath(path.join(directory, "src", "index.ts")))
-
-        expect(result).toMatchObject({ truncated: false, partial: false })
-        expect(result.items).toHaveLength(1)
-        expect(result.items[0]).toMatchObject({
-          path: RelativePath.make("src/index.ts"),
-          canonical,
-          resource: "src/index.ts",
-        })
-        expect(typeof result.items[0].mtime).toBe("number")
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("searches files under a relative subdirectory", () =>
-    withTmp((directory) => {
-      const docs = path.join(directory, "docs")
-      return Effect.gen(function* () {
-        yield* Effect.promise(async () => {
-          await fs.mkdir(path.join(directory, "src"))
-          await fs.mkdir(docs)
-          await fs.writeFile(path.join(directory, "src", "active.ts"), "active\n")
-          await fs.writeFile(path.join(docs, "guide.md"), "guide\n")
-        })
-        const search = yield* LocationSearch.Service
-
-        expect(
-          (yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
-        ).toEqual([RelativePath.make("src/active.ts")])
-      }).pipe(provide(directory))
-    }),
-  )
-
-  it.live("greps the Location, exact relative files and directories, and include globs", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(async () => {
-          await fs.mkdir(path.join(directory, "src"))
-          await fs.writeFile(path.join(directory, "src", "one.ts"), "needle ts\n")
-          await fs.writeFile(path.join(directory, "src", "two.txt"), "needle txt\n")
-          await fs.writeFile(path.join(directory, "root.md"), "needle root\n")
-        })
-        const search = yield* LocationSearch.Service
-
-        expect((yield* search.grep({ pattern: "needle" })).items.map((item) => item.path).sort()).toEqual([
-          RelativePath.make("root.md"),
-          RelativePath.make("src/one.ts"),
-          RelativePath.make("src/two.txt"),
-        ])
-        expect(
-          (yield* search.grep({ pattern: "needle", path: RelativePath.make("src") })).items
-            .map((item) => item.path)
-            .sort(),
-        ).toEqual([RelativePath.make("src/one.ts"), RelativePath.make("src/two.txt")])
-        expect((yield* search.grep({ pattern: "needle", path: RelativePath.make("src/one.ts") })).items).toMatchObject([
-          { path: RelativePath.make("src/one.ts"), resource: "src/one.ts", lines: "needle ts\n", line: 1, offset: 0 },
-        ])
-        expect((yield* search.grep({ pattern: "needle", include: "*.ts" })).items.map((item) => item.path)).toEqual([
-          RelativePath.make("src/one.ts"),
-        ])
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("does not discover hidden files during broad V2 searches", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(async () => {
-          await fs.mkdir(path.join(directory, "nested", ".private"), { recursive: true })
-          await fs.writeFile(path.join(directory, "visible.txt"), "needle visible\n")
-          await fs.writeFile(path.join(directory, ".env"), "needle root secret\n")
-          await fs.writeFile(path.join(directory, "nested", "visible.txt"), "needle nested visible\n")
-          await fs.writeFile(path.join(directory, "nested", ".env"), "needle nested secret\n")
-          await fs.writeFile(path.join(directory, "nested", ".private", "secret.txt"), "needle hidden directory\n")
-        })
-        const search = yield* LocationSearch.Service
-
-        expect((yield* search.files({ pattern: "*" })).items.map((item) => item.path).sort()).toEqual([
-          RelativePath.make("nested/visible.txt"),
-          RelativePath.make("visible.txt"),
-        ])
-        expect((yield* search.files({ pattern: ".env" })).items).toEqual([])
-        expect((yield* search.grep({ pattern: "needle", include: "*" })).items.map((item) => item.path).sort()).toEqual(
-          [RelativePath.make("nested/visible.txt"), RelativePath.make("visible.txt")],
-        )
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("caps result counts and line previews", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(async () => {
-          await Promise.all(
-            Array.from({ length: 101 }, (_, index) => fs.writeFile(path.join(directory, `${index}.txt`), "needle\n")),
-          )
-          await fs.writeFile(
-            path.join(directory, "long.txt"),
-            `needle ${"x".repeat(LocationSearch.MAX_LINE_PREVIEW_LENGTH)}\n`,
-          )
-        })
-        const search = yield* LocationSearch.Service
-        const files = yield* search.files({ pattern: "*.txt", limit: 2 })
-        const hardCappedFiles = yield* search.files({ pattern: "*.txt", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
-        const hardCappedGrep = yield* search.grep({ pattern: "needle", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })
-        const grep = yield* search.grep({ pattern: "needle", path: RelativePath.make("long.txt") })
-
-        expect(files.items).toHaveLength(2)
-        expect(files.truncated).toBe(true)
-        expect(hardCappedFiles.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
-        expect(hardCappedFiles.truncated).toBe(true)
-        expect(hardCappedGrep.items).toHaveLength(LocationSearch.MAX_RESULT_LIMIT)
-        expect(hardCappedGrep.truncated).toBe(true)
-        expect(grep.items[0].lines).toHaveLength(LocationSearch.MAX_LINE_PREVIEW_LENGTH)
-        expect(grep.items[0].linePreviewTruncated).toBe(true)
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("reports invalid regex as a typed failure", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(() => fs.writeFile(path.join(directory, "notes.txt"), "notes\n"))
-        const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "[" }).pipe(Effect.exit)
-
-        expect(Exit.isFailure(exit)).toBe(true)
-        if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Ripgrep.InvalidPatternError)
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("rejects oversized ripgrep JSON records before durable projection", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        yield* Effect.promise(() =>
-          fs.writeFile(path.join(directory, "huge.txt"), `needle ${"x".repeat(Ripgrep.MAX_RECORD_BYTES)}\n`),
-        )
-        const exit = yield* (yield* LocationSearch.Service).grep({ pattern: "needle" }).pipe(Effect.exit)
-
-        expect(Exit.isFailure(exit)).toBe(true)
-        if (Exit.isFailure(exit)) expect(String(Cause.squash(exit.cause))).toContain("Ripgrep JSON record exceeded")
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("rejects lexical and symlink escapes through root resolution", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        if (process.platform === "win32") return
-        const outside = `${directory}-outside`
-        yield* Effect.promise(async () => {
-          await fs.mkdir(outside)
-          await fs.writeFile(path.join(outside, "secret.txt"), "secret\n")
-          await fs.symlink(outside, path.join(directory, "escape"))
-        })
-        const search = yield* LocationSearch.Service
-
-        expect(
-          Exit.isFailure(
-            yield* search.files({ pattern: "*", path: RelativePath.make("../outside") }).pipe(Effect.exit),
-          ),
-        ).toBe(true)
-        expect(
-          Exit.isFailure(yield* search.files({ pattern: "*", path: RelativePath.make("escape") }).pipe(Effect.exit)),
-        ).toBe(true)
-        yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  it.live("honors a pre-aborted cancellation signal", () =>
-    withTmp((directory) =>
-      Effect.gen(function* () {
-        const controller = new AbortController()
-        controller.abort()
-        const exit = yield* (yield* LocationSearch.Service)
-          .files({ pattern: "*", signal: controller.signal })
-          .pipe(Effect.exit)
-        expect(Exit.isFailure(exit)).toBe(true)
-      }).pipe(provide(directory)),
-    ),
-  )
-
-  test("exposes schema-testable search bounds", () => {
-    const decode = Schema.decodeUnknownSync(LocationSearch.FilesInput)
-    expect(LocationSearch.DEFAULT_RESULT_LIMIT).toBe(100)
-    expect(LocationSearch.MAX_RESULT_LIMIT).toBe(100)
-    expect(LocationSearch.MAX_LINE_PREVIEW_LENGTH).toBe(2_000)
-    expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
-  })
-})

+ 39 - 0
packages/core/test/ripgrep.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { Effect } from "effect"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
+import { RelativePath } from "@opencode-ai/core/schema"
+import { tmpdir } from "./fixture/tmpdir"
+import { testEffect } from "./lib/effect"
+
+const it = testEffect(Ripgrep.defaultLayer)
+
+describe("Ripgrep", () => {
+  it.live("allows caller globs to re-include git metadata", () =>
+    Effect.acquireUseRelease(
+      Effect.promise(() => tmpdir()),
+      (tmp) =>
+        Effect.gen(function* () {
+          yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
+          yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".opencode", "config"), "needle\n"))
+          yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".git")))
+          yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, ".git", "config"), "needle\n"))
+          const ripgrep = yield* Ripgrep.Service
+
+          const files = yield* ripgrep.find({ cwd: tmp.path, pattern: "**/*", limit: 10 })
+          expect(files.map((item) => item.path)).toContain(RelativePath.make(".opencode/config"))
+          expect(files.map((item) => item.path)).toContain(RelativePath.make(".git/config"))
+
+          const matches = yield* ripgrep.grep({ cwd: tmp.path, pattern: "needle", include: "config", limit: 10 })
+          expect(matches.map((item) => item.entry.path)).toContain(
+            RelativePath.make(".opencode/config"),
+          )
+          expect(matches.map((item) => item.entry.path)).toContain(
+            RelativePath.make(".git/config"),
+          )
+        }),
+      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+    ),
+  )
+})

+ 0 - 153
packages/core/test/tool-glob.test.ts

@@ -1,153 +0,0 @@
-import { describe, expect } from "bun:test"
-import { Effect, Layer } from "effect"
-import { LocationSearch } from "@opencode-ai/core/location-search"
-import { PermissionV2 } from "@opencode-ai/core/permission"
-import { RelativePath } from "@opencode-ai/core/schema"
-import { SessionV2 } from "@opencode-ai/core/session"
-import { GlobTool } from "@opencode-ai/core/tool/glob"
-import { ToolRegistry } from "@opencode-ai/core/tool/registry"
-import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
-
-const sessionID = SessionV2.ID.make("ses_glob_tool_test")
-const assertions: PermissionV2.AssertInput[] = []
-const searches: LocationSearch.FilesInput[] = []
-let allow = true
-let result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
-
-const permission = Layer.succeed(
-  PermissionV2.Service,
-  PermissionV2.Service.of({
-    assert: (input) =>
-      Effect.sync(() => assertions.push(input)).pipe(
-        Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] }))),
-      ),
-    ask: () => Effect.die("unused"),
-    reply: () => Effect.die("unused"),
-    get: () => Effect.die("unused"),
-    forSession: () => Effect.die("unused"),
-    list: () => Effect.die("unused"),
-  }),
-)
-
-const search = Layer.succeed(
-  LocationSearch.Service,
-  LocationSearch.Service.of({
-    files: (input) =>
-      Effect.sync(() => {
-        searches.push(input)
-        return result
-      }),
-    grep: () => Effect.die("unused"),
-  }),
-)
-
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-const glob = GlobTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(search))
-const it = testEffect(Layer.mergeAll(registry, permission, search, glob))
-
-const reset = () => {
-  assertions.length = 0
-  searches.length = 0
-  allow = true
-  result = new LocationSearch.FilesResult({ items: [], truncated: false, partial: false })
-}
-
-const call = (input: typeof GlobTool.Input.Type, id = "call-glob") => ({
-  sessionID,
-  ...toolIdentity,
-  call: { type: "tool-call" as const, id, name: "glob", input },
-})
-
-describe("GlobTool", () => {
-  it.effect("registers the glob definition", () =>
-    Effect.gen(function* () {
-      reset()
-      expect((yield* toolDefinitions(yield* ToolRegistry.Service)).map((tool) => tool.name)).toEqual(["glob"])
-    }),
-  )
-
-  it.effect("authorizes the active Location pattern and delegates traversal only to LocationSearch.files", () =>
-    Effect.gen(function* () {
-      reset()
-      const registry = yield* ToolRegistry.Service
-
-      expect(
-        yield* executeTool(registry, call({ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 })),
-      ).toEqual({
-        type: "text",
-        value: "No files found",
-      })
-      expect(assertions).toMatchObject([
-        {
-          sessionID,
-          action: "glob",
-          resources: ["**/*.ts"],
-          save: ["*"],
-          metadata: { root: "src", path: "src", limit: 12 },
-        },
-      ])
-      expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
-    }),
-  )
-
-  it.effect("prevents Location search when permission is denied", () =>
-    Effect.gen(function* () {
-      reset()
-      allow = false
-
-      expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.secret" }))).toEqual({
-        type: "error",
-        value: "Unable to find files matching *.secret",
-      })
-      expect(searches).toEqual([])
-    }),
-  )
-
-  it.effect("returns active Location glob resources", () =>
-    Effect.gen(function* () {
-      reset()
-      result = new LocationSearch.FilesResult({
-        items: [
-          new LocationSearch.File({
-            path: RelativePath.make("src/index.ts"),
-            canonical: "/project/src/index.ts",
-            resource: "src/index.ts",
-            mtime: 1,
-          }),
-        ],
-        truncated: false,
-        partial: false,
-      })
-
-      expect(yield* settleTool(yield* ToolRegistry.Service, call({ pattern: "*.ts" }))).toEqual({
-        result: { type: "text", value: "src/index.ts" },
-        output: {
-          structured: result,
-          content: [{ type: "text", text: "src/index.ts" }],
-        },
-      })
-    }),
-  )
-
-  it.effect("formats bounded and partial results without discarding structured output", () =>
-    Effect.sync(() => {
-      const output = new LocationSearch.FilesResult({
-        items: [
-          new LocationSearch.File({
-            path: RelativePath.make("one.ts"),
-            canonical: "/project/one.ts",
-            resource: "one.ts",
-            mtime: 1,
-          }),
-        ],
-        truncated: true,
-        partial: true,
-      })
-
-      expect(GlobTool.toModelOutput(output)).toBe(
-        "one.ts\n\n(Results are truncated: showing first 1 results. Consider using a more specific path or pattern.)\n\n(Results may be incomplete because some discovered files could not be read.)",
-      )
-    }),
-  )
-})

+ 0 - 217
packages/core/test/tool-grep.test.ts

@@ -1,217 +0,0 @@
-import fs from "fs/promises"
-import path from "path"
-import { describe, expect } from "bun:test"
-import { Effect, Exit, Layer } from "effect"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Location } from "@opencode-ai/core/location"
-import { FileSystem } from "@opencode-ai/core/filesystem"
-import { Search } from "@opencode-ai/core/filesystem/search"
-import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
-import { LocationSearch } from "@opencode-ai/core/location-search"
-import { PermissionV2 } from "@opencode-ai/core/permission"
-import { AppProcess } from "@opencode-ai/core/process"
-import { Ripgrep } from "@opencode-ai/core/ripgrep"
-import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
-import { SessionV2 } from "@opencode-ai/core/session"
-import { GrepTool } from "@opencode-ai/core/tool/grep"
-import { ToolRegistry } from "@opencode-ai/core/tool/registry"
-import { location } from "./fixture/location"
-import { tmpdir } from "./fixture/tmpdir"
-import { it as runtimeIt } from "./lib/effect"
-import { testEffect } from "./lib/effect"
-import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
-
-const assertions: PermissionV2.AssertInput[] = []
-const searches: LocationSearch.GrepInput[] = []
-let allow = true
-let result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
-let searchFailure: Ripgrep.InvalidPatternError | undefined
-
-const search = Layer.succeed(
-  LocationSearch.Service,
-  LocationSearch.Service.of({
-    files: () => Effect.die("unused"),
-    grep: (input) =>
-      Effect.sync(() => {
-        searches.push(input)
-        if (searchFailure) throw searchFailure
-        return result
-      }),
-  }),
-)
-const permission = Layer.succeed(
-  PermissionV2.Service,
-  PermissionV2.Service.of({
-    assert: (input) =>
-      Effect.sync(() => {
-        assertions.push(input)
-      }).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
-    ask: () => Effect.die("unused"),
-    reply: () => Effect.die("unused"),
-    get: () => Effect.die("unused"),
-    forSession: () => Effect.die("unused"),
-    list: () => Effect.die("unused"),
-  }),
-)
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-const grep = GrepTool.layer.pipe(Layer.provide(registry), Layer.provide(search), Layer.provide(permission))
-const it = testEffect(Layer.mergeAll(registry, search, permission, grep))
-const sessionID = SessionV2.ID.make("ses_grep_tool_test")
-
-const execute = (input: Record<string, unknown>) =>
-  ToolRegistry.Service.use((registry) =>
-    executeTool(registry, {
-      sessionID,
-      ...toolIdentity,
-      call: { type: "tool-call", id: "call-grep", name: "grep", input },
-    }),
-  )
-
-const settle = (input: Record<string, unknown>) =>
-  ToolRegistry.Service.use((registry) =>
-    settleTool(registry, {
-      sessionID,
-      ...toolIdentity,
-      call: { type: "tool-call", id: "call-grep", name: "grep", input },
-    }),
-  )
-
-const reset = () => {
-  assertions.length = 0
-  searches.length = 0
-  allow = true
-  searchFailure = undefined
-  result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
-}
-
-function provideLive(directory: string) {
-  const dependencies = Layer.mergeAll(
-    FSUtil.defaultLayer,
-    FileSystemRipgrep.defaultLayer,
-    Search.defaultLayer,
-    AppProcess.defaultLayer,
-    Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
-  )
-  const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
-  const search = LocationSearch.layer.pipe(
-    Layer.provide(filesystem),
-    Layer.provide(Ripgrep.layer.pipe(Layer.provide(dependencies))),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(dependencies),
-  )
-  const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
-  const grep = GrepTool.layer.pipe(
-    Layer.provide(registry),
-    Layer.provide(filesystem),
-    Layer.provide(search),
-    Layer.provide(permission),
-  )
-  return Layer.mergeAll(registry, filesystem, search, permission, grep)
-}
-
-describe("GrepTool", () => {
-  it.effect("registers grep", () =>
-    Effect.gen(function* () {
-      reset()
-      expect(yield* toolDefinitions(yield* ToolRegistry.Service)).toMatchObject([{ name: "grep" }])
-    }),
-  )
-
-  it.effect("authorizes the regex resource and delegates an active Location grep", () =>
-    Effect.gen(function* () {
-      reset()
-      const input = { pattern: "needle", path: "src", include: "*.ts", limit: 2 }
-
-      expect(yield* execute(input)).toEqual({ type: "text", value: "No files found" })
-      expect(assertions).toMatchObject([
-        {
-          sessionID,
-          action: "grep",
-          resources: ["needle"],
-          save: ["*"],
-          metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
-        },
-      ])
-      expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
-    }),
-  )
-
-  it.effect("does not search when permission is denied", () =>
-    Effect.gen(function* () {
-      reset()
-      allow = false
-
-      expect(yield* execute({ pattern: "secret" })).toEqual({ type: "error", value: "Unable to grep for secret" })
-      expect(assertions).toHaveLength(1)
-      expect(searches).toEqual([])
-    }),
-  )
-
-  it.effect("keeps structured results raw while formatting bounded partial previews for models", () =>
-    Effect.gen(function* () {
-      reset()
-      result = new LocationSearch.GrepResult({
-        items: [
-          new LocationSearch.Match({
-            path: RelativePath.make("src/index.ts"),
-            canonical: "/project/src/index.ts",
-            resource: "src/index.ts",
-            lines: "needle preview",
-            linePreviewTruncated: true,
-            line: 3,
-            offset: 8,
-            submatches: [new LocationSearch.Submatch({ text: "needle", start: 0, end: 6 })],
-            mtime: 1,
-          }),
-        ],
-        truncated: true,
-        partial: true,
-      })
-
-      const settlement = yield* settle({ pattern: "needle" })
-      expect(settlement.output?.structured).toEqual(result)
-      expect(settlement.result).toEqual({
-        type: "text",
-        value:
-          "Found 1 matches\nsrc/index.ts:\n  Line 3: needle preview...\n\n(Results are truncated: showing first 1 matches. Consider using a more specific path or pattern.)\n\n(Some paths were inaccessible and skipped)",
-      })
-    }),
-  )
-
-  it.effect("preserves an unexpected search defect", () =>
-    Effect.gen(function* () {
-      reset()
-      searchFailure = new Ripgrep.InvalidPatternError({
-        pattern: "[",
-        message: "regex parse error: unclosed character class",
-      })
-
-      expect(Exit.isFailure(yield* execute({ pattern: "[" }).pipe(Effect.exit))).toBe(true)
-      expect(searches).toEqual([{ pattern: "[" }])
-    }),
-  )
-
-  runtimeIt.live("greps active Location files with include globs", () =>
-    Effect.acquireRelease(
-      Effect.promise(() => tmpdir()),
-      (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
-    ).pipe(
-      Effect.flatMap((tmp) => {
-        const docs = path.join(tmp.path, "docs")
-        return Effect.gen(function* () {
-          reset()
-          yield* Effect.promise(async () => {
-            await fs.mkdir(path.join(tmp.path, "src"))
-            await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
-            await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
-          })
-
-          expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
-            type: "text",
-            value: "Found 1 matches\nsrc/index.ts:\n  Line 1: needle ts\n",
-          })
-        }).pipe(Effect.provide(provideLive(tmp.path)))
-      }),
-    ),
-  )
-})

+ 1 - 0
packages/opencode/package.json

@@ -78,6 +78,7 @@
     "@clack/prompts": "1.0.0-alpha.1",
     "@clack/prompts": "1.0.0-alpha.1",
     "@effect/opentelemetry": "catalog:",
     "@effect/opentelemetry": "catalog:",
     "@effect/platform-node": "catalog:",
     "@effect/platform-node": "catalog:",
+    "@ff-labs/fff-bun": "0.9.3",
     "@gitlab/opencode-gitlab-auth": "1.3.3",
     "@gitlab/opencode-gitlab-auth": "1.3.3",
     "@modelcontextprotocol/sdk": "1.29.0",
     "@modelcontextprotocol/sdk": "1.29.0",
     "@octokit/graphql": "9.0.2",
     "@octokit/graphql": "9.0.2",

+ 11 - 30
packages/opencode/script/bench-search.ts

@@ -1,10 +1,11 @@
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
 import { Fff } from "@opencode-ai/core/filesystem/fff.bun"
 import { AppRuntime } from "@/effect/app-runtime"
 import { AppRuntime } from "@/effect/app-runtime"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { AbsolutePath } from "@opencode-ai/core/schema"
 import { InstanceStore } from "@/project/instance-store"
 import { InstanceStore } from "@/project/instance-store"
 
 
-const dir = process.cwd()
+const dir = AbsolutePath.make(process.cwd())
 
 
 const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
 const FILE_QUERIES = ["fff", "package.json", "tools/ experiment"]
 const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
 const GREP_QUERIES = ["FileFinder", "import", "grep", "autocomplete"]
@@ -14,7 +15,7 @@ const FILE_LIMIT = 100
 const GREP_LIMIT = 50
 const GREP_LIMIT = 50
 const GLOB_LIMIT = 50
 const GLOB_LIMIT = 50
 
 
-const run = <A>(effect: Effect.Effect<A, unknown, Search.Service>) =>
+const run = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
   AppRuntime.runPromise(
   AppRuntime.runPromise(
     InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
     InstanceStore.Service.use((store) => store.provide({ directory: dir }, effect as never)),
   ) as Promise<A>
   ) as Promise<A>
@@ -57,32 +58,16 @@ for (const q of GREP_QUERIES) {
 
 
 picker.destroy()
 picker.destroy()
 
 
-// --- Ripgrep service (via Search with file:["."] to force rg path) ---
-console.log()
-console.log("--- Ripgrep (via Search service) ---")
-
-// warmup
-await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_rg_", limit: 1, file: ["."] })))
-
-for (const q of GREP_QUERIES) {
-  const t = performance.now()
-  const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT, file: ["."] })))
-  console.log(
-    `[ripgrep] grep "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} total, limit is per-file not total)`,
-  )
-}
-
 // --- Search service: init breakdown ---
 // --- Search service: init breakdown ---
 console.log()
 console.log()
 
 
 // 1) runtime + InstanceState + picker create + scan poll
 // 1) runtime + InstanceState + picker create + scan poll
 const tRuntime = performance.now()
 const tRuntime = performance.now()
-await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: "_warmup_file_", limit: 1 })))
 console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
 console.log(`[Search] init file (runtime + picker + scan): ${(performance.now() - tRuntime).toFixed(1)}ms`)
 
 
 // 2) grep warmup (content index cold-start inside the Search service picker)
 // 2) grep warmup (content index cold-start inside the Search service picker)
 const tGrepWarmup = performance.now()
 const tGrepWarmup = performance.now()
-await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: "_warmup_grep_", limit: 1 })))
+await run(FileSystem.Service.use((svc) => svc.grep({ pattern: "_warmup_grep_", limit: 1 })))
 console.log(`[Search] init grep (content index warmup):    ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
 console.log(`[Search] init grep (content index warmup):    ${(performance.now() - tGrepWarmup).toFixed(1)}ms`)
 
 
 console.log()
 console.log()
@@ -90,24 +75,20 @@ console.log("--- Search service (warm) ---")
 
 
 for (const q of FILE_QUERIES) {
 for (const q of FILE_QUERIES) {
   const t = performance.now()
   const t = performance.now()
-  const r = await run(Search.Service.use((svc) => svc.file({ cwd: dir, query: q, limit: FILE_LIMIT })))
-  console.log(`[Search.file] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`)
+  const r = await run(FileSystem.Service.use((svc) => svc.find({ query: q, limit: FILE_LIMIT })))
+  console.log(`[Search.find] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} results)`)
 }
 }
 
 
 for (const q of GREP_QUERIES) {
 for (const q of GREP_QUERIES) {
   const t = performance.now()
   const t = performance.now()
-  const r = await run(Search.Service.use((svc) => svc.search({ cwd: dir, pattern: q, limit: GREP_LIMIT })))
-  console.log(
-    `[Search.search] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.items.length} matches, engine=${r.engine})`,
-  )
+  const r = await run(FileSystem.Service.use((svc) => svc.grep({ pattern: q, limit: GREP_LIMIT })))
+  console.log(`[Search.grep] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} matches)`)
 }
 }
 
 
 for (const q of GLOB_QUERIES) {
 for (const q of GLOB_QUERIES) {
   const t = performance.now()
   const t = performance.now()
-  const r = await run(Search.Service.use((svc) => svc.glob({ cwd: dir, pattern: q, limit: GLOB_LIMIT })))
-  console.log(
-    `[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.files.length} files, truncated=${r.truncated})`,
-  )
+  const r = await run(FileSystem.Service.use((svc) => svc.glob({ pattern: q, limit: GLOB_LIMIT })))
+  console.log(`[Search.glob] "${q}": ${(performance.now() - t).toFixed(1)}ms (${r.length} files)`)
 }
 }
 
 
 process.exit(0)
 process.exit(0)

+ 3 - 1
packages/opencode/script/build.ts

@@ -140,6 +140,7 @@ const binaries: Record<string, string> = {}
 if (!skipInstall) {
 if (!skipInstall) {
   await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
   await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
   await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
   await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
+  await $`bun install --os="*" --cpu="*" @ff-labs/fff-bun@${pkg.dependencies["@ff-labs/fff-bun"]}`
 }
 }
 for (const item of targets) {
 for (const item of targets) {
   const name = [
   const name = [
@@ -165,7 +166,7 @@ for (const item of targets) {
   const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
   const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
 
 
   await Bun.build({
   await Bun.build({
-    conditions: ["node"],
+    conditions: ["bun", "node"],
     tsconfig: "./tsconfig.json",
     tsconfig: "./tsconfig.json",
     plugins: [plugin],
     plugins: [plugin],
     external: ["node-gyp"],
     external: ["node-gyp"],
@@ -186,6 +187,7 @@ for (const item of targets) {
     files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
     files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {},
     entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
     entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
     define: {
     define: {
+      FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"),
       OPENCODE_VERSION: `'${Script.version}'`,
       OPENCODE_VERSION: `'${Script.version}'`,
       OPENCODE_MODELS_DEV: generated.modelsData,
       OPENCODE_MODELS_DEV: generated.modelsData,
       OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
       OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,

+ 5 - 18
packages/opencode/src/cli/cmd/debug/file.ts

@@ -2,7 +2,6 @@ import { EOL } from "os"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
-import { Search } from "@opencode-ai/core/filesystem/search"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { effectCmd } from "../../effect-cmd"
 import { effectCmd } from "../../effect-cmd"
 import { cmd } from "../cmd"
 import { cmd } from "../cmd"
@@ -23,7 +22,11 @@ const FileSearchCommand = effectCmd({
       description: "Search query",
       description: "Search query",
     }),
     }),
   handler: Effect.fn("Cli.debug.file.search")(function* (args) {
   handler: Effect.fn("Cli.debug.file.search")(function* (args) {
-    const results = yield* filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query })))
+    const results = yield* Effect.orDie(
+      filesystem(
+        FileSystem.Service.use((svc) => svc.find({ query: args.query })),
+      ),
+    )
     process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
     process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
   }),
   }),
 })
 })
@@ -58,21 +61,6 @@ const FileListCommand = effectCmd({
   }),
   }),
 })
 })
 
 
-const FileTreeCommand = effectCmd({
-  command: "tree [dir]",
-  describe: "show directory tree",
-  builder: (yargs) =>
-    yargs.positional("dir", {
-      type: "string",
-      description: "Directory to tree",
-      default: process.cwd(),
-    }),
-  handler: Effect.fn("Cli.debug.file.tree")(function* (args) {
-    const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 })))
-    console.log(JSON.stringify(tree, null, 2))
-  }),
-})
-
 export const FileCommand = cmd({
 export const FileCommand = cmd({
   command: "file",
   command: "file",
   describe: "file system debugging utilities",
   describe: "file system debugging utilities",
@@ -81,7 +69,6 @@ export const FileCommand = cmd({
       .command(FileReadCommand)
       .command(FileReadCommand)
       .command(FileListCommand)
       .command(FileListCommand)
       .command(FileSearchCommand)
       .command(FileSearchCommand)
-      .command(FileTreeCommand)
       .demandCommand(),
       .demandCommand(),
   async handler() {},
   async handler() {},
 })
 })

+ 20 - 40
packages/opencode/src/cli/cmd/debug/ripgrep.ts

@@ -1,6 +1,6 @@
 import { EOL } from "os"
 import { EOL } from "os"
-import { Effect, Stream } from "effect"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Effect } from "effect"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { effectCmd } from "../../effect-cmd"
 import { effectCmd } from "../../effect-cmd"
 import { cmd } from "../cmd"
 import { cmd } from "../cmd"
 import { InstanceRef } from "@/effect/instance-ref"
 import { InstanceRef } from "@/effect/instance-ref"
@@ -8,25 +8,10 @@ import { InstanceRef } from "@/effect/instance-ref"
 export const RipgrepCommand = cmd({
 export const RipgrepCommand = cmd({
   command: "rg",
   command: "rg",
   describe: "ripgrep debugging utilities",
   describe: "ripgrep debugging utilities",
-  builder: (yargs) => yargs.command(TreeCommand).command(FilesCommand).command(SearchCommand).demandCommand(),
+  builder: (yargs) => yargs.command(FilesCommand).command(SearchCommand).demandCommand(),
   async handler() {},
   async handler() {},
 })
 })
 
 
-const TreeCommand = effectCmd({
-  command: "tree",
-  describe: "show file tree using ripgrep",
-  builder: (yargs) =>
-    yargs.option("limit", {
-      type: "number",
-    }),
-  handler: Effect.fn("Cli.debug.rg.tree")(function* (args) {
-    const ctx = yield* InstanceRef
-    if (!ctx) return
-    const tree = yield* Effect.orDie(Search.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })))
-    process.stdout.write(tree + EOL)
-  }),
-})
-
 const FilesCommand = effectCmd({
 const FilesCommand = effectCmd({
   command: "files",
   command: "files",
   describe: "list files using ripgrep",
   describe: "list files using ripgrep",
@@ -47,19 +32,15 @@ const FilesCommand = effectCmd({
   handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
   handler: Effect.fn("Cli.debug.rg.files")(function* (args) {
     const ctx = yield* InstanceRef
     const ctx = yield* InstanceRef
     if (!ctx) return
     if (!ctx) return
-    const search = yield* Search.Service
-    const files = yield* search
-      .files({
+    const ripgrep = yield* Ripgrep.Service
+    const files = yield* ripgrep
+      .glob({
         cwd: ctx.directory,
         cwd: ctx.directory,
-        glob: args.glob ? [args.glob] : undefined,
+        pattern: args.glob ?? "**/*",
+        limit: args.limit ?? 10_000,
       })
       })
-      .pipe(
-        Stream.take(args.limit ?? Infinity),
-        Stream.runCollect,
-        Effect.map((c) => [...c]),
-        Effect.orDie,
-      )
-    process.stdout.write(files.join(EOL) + EOL)
+      .pipe(Effect.orDie)
+    process.stdout.write(files.map((file) => file.path).join(EOL) + EOL)
   }),
   }),
 })
 })
 
 
@@ -84,16 +65,15 @@ const SearchCommand = effectCmd({
   handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
   handler: Effect.fn("Cli.debug.rg.search")(function* (args) {
     const ctx = yield* InstanceRef
     const ctx = yield* InstanceRef
     if (!ctx) return
     if (!ctx) return
-    const results = yield* Effect.orDie(
-      Search.Service.use((svc) =>
-        svc.search({
-          cwd: ctx.directory,
-          pattern: args.pattern,
-          glob: args.glob as string[] | undefined,
-          limit: args.limit,
-        }),
-      ),
-    )
-    process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
+    const ripgrep = yield* Ripgrep.Service
+    const results = yield* ripgrep
+      .grep({
+        cwd: ctx.directory,
+        pattern: args.pattern,
+        include: args.glob?.[0],
+        limit: args.limit ?? 10_000,
+      })
+      .pipe(Effect.orDie)
+    process.stdout.write(JSON.stringify(results, null, 2) + EOL)
   }),
   }),
 })
 })

+ 6 - 5
packages/opencode/src/effect/app-runtime.ts

@@ -8,8 +8,7 @@ import { Auth } from "@/auth"
 import { Account } from "@/account/account"
 import { Account } from "@/account/account"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { Git } from "@/git"
 import { Git } from "@/git"
-import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Storage } from "@/storage/storage"
 import { Storage } from "@/storage/storage"
 import { Snapshot } from "@/snapshot"
 import { Snapshot } from "@/snapshot"
 import { Plugin } from "@/plugin"
 import { Plugin } from "@/plugin"
@@ -61,8 +60,6 @@ export const AppLayer = Layer.mergeAll(
   Account.defaultLayer,
   Account.defaultLayer,
   Config.defaultLayer,
   Config.defaultLayer,
   Git.defaultLayer,
   Git.defaultLayer,
-  Ripgrep.defaultLayer,
-  Search.defaultLayer,
   Storage.defaultLayer,
   Storage.defaultLayer,
   Snapshot.defaultLayer,
   Snapshot.defaultLayer,
   Plugin.defaultLayer,
   Plugin.defaultLayer,
@@ -102,7 +99,11 @@ export const AppLayer = Layer.mergeAll(
   Installation.defaultLayer,
   Installation.defaultLayer,
   ShareNext.defaultLayer,
   ShareNext.defaultLayer,
   SessionShare.defaultLayer,
   SessionShare.defaultLayer,
-).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
+).pipe(
+  Layer.provideMerge(Ripgrep.defaultLayer),
+  Layer.provideMerge(InstanceLayer.layer),
+  Layer.provideMerge(Observability.layer),
+)
 
 
 const rt = ManagedRuntime.make(AppLayer, { memoMap })
 const rt = ManagedRuntime.make(AppLayer, { memoMap })
 type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
 type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">

+ 0 - 14
packages/opencode/src/project/bootstrap.ts

@@ -6,9 +6,7 @@ import { Snapshot } from "../snapshot"
 import * as Project from "./project"
 import * as Project from "./project"
 import * as Vcs from "./vcs"
 import * as Vcs from "./vcs"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
-import { registerDisposer } from "@/effect/instance-registry"
 import { ShareNext } from "@/share/share-next"
 import { ShareNext } from "@/share/share-next"
-import { Search } from "@opencode-ai/core/filesystem/search"
 import { Effect, Layer } from "effect"
 import { Effect, Layer } from "effect"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { Service } from "./bootstrap-service"
 import { Service } from "./bootstrap-service"
@@ -27,25 +25,15 @@ export const layer = Layer.effect(
     const lsp = yield* LSP.Service
     const lsp = yield* LSP.Service
     const plugin = yield* Plugin.Service
     const plugin = yield* Plugin.Service
     const project = yield* Project.Service
     const project = yield* Project.Service
-    const search = yield* Search.Service
     const shareNext = yield* ShareNext.Service
     const shareNext = yield* ShareNext.Service
     const snapshot = yield* Snapshot.Service
     const snapshot = yield* Snapshot.Service
     const vcs = yield* Vcs.Service
     const vcs = yield* Vcs.Service
 
 
-    // once we dispose the service - also release all the internal fff resources
-    const off = registerDisposer((directory) => Effect.runPromise(search.release(directory)))
-    yield* Effect.addFinalizer(() => Effect.sync(off))
-
     const run = Effect.gen(function* () {
     const run = Effect.gen(function* () {
       const ctx = yield* InstanceState.context
       const ctx = yield* InstanceState.context
       yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
       yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
       // everything depends on config so eager load it for nice traces
       // everything depends on config so eager load it for nice traces
       yield* config.get()
       yield* config.get()
-      // in 99% of use cases user that is opened opencode at certain directory will
-      // conduct a file search in this direcotry, it could be switched later but
-      // mostly always we will need a file picker for cwd
-      // so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
-      yield* search.warm(ctx.directory).pipe(Effect.ignore)
       // Plugin can mutate config so it has to be initialized before anything else.
       // Plugin can mutate config so it has to be initialized before anything else.
       yield* plugin.init()
       yield* plugin.init()
       // Each service self-manages its own slow work via Effect.forkScoped against
       // Each service self-manages its own slow work via Effect.forkScoped against
@@ -68,7 +56,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
     LSP.defaultLayer,
     LSP.defaultLayer,
     Plugin.defaultLayer,
     Plugin.defaultLayer,
     Project.defaultLayer,
     Project.defaultLayer,
-    Search.defaultLayer,
     ShareNext.defaultLayer,
     ShareNext.defaultLayer,
     Snapshot.defaultLayer,
     Snapshot.defaultLayer,
     Vcs.defaultLayer,
     Vcs.defaultLayer,
@@ -81,7 +68,6 @@ export const node = LayerNode.make(layer, [
   LSP.node,
   LSP.node,
   Plugin.node,
   Plugin.node,
   Project.node,
   Project.node,
-  Search.node,
   ShareNext.node,
   ShareNext.node,
   Snapshot.node,
   Snapshot.node,
   Vcs.node,
   Vcs.node,

+ 15 - 2
packages/opencode/src/server/routes/instance/httpapi/groups/file.ts

@@ -1,5 +1,4 @@
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FileSystem } from "@opencode-ai/core/filesystem"
-import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
 import { Schema } from "effect"
 import { Schema } from "effect"
@@ -38,6 +37,20 @@ export const FindSymbolQuery = Schema.Struct({
   query: Schema.String,
   query: Schema.String,
 })
 })
 
 
+export const LegacyMatch = Schema.Struct({
+  path: Schema.Struct({ text: Schema.String }),
+  lines: Schema.Struct({ text: Schema.String }),
+  line_number: NonNegativeInt,
+  absolute_offset: NonNegativeInt,
+  submatches: Schema.Array(
+    Schema.Struct({
+      match: Schema.Struct({ text: Schema.String }),
+      start: NonNegativeInt,
+      end: NonNegativeInt,
+    }),
+  ),
+})
+
 export const LegacyEntry = Schema.Struct({
 export const LegacyEntry = Schema.Struct({
   name: Schema.String,
   name: Schema.String,
   path: Schema.String,
   path: Schema.String,
@@ -94,7 +107,7 @@ export const FileApi = HttpApi.make("file")
       .add(
       .add(
         HttpApiEndpoint.get("findText", FilePaths.findText, {
         HttpApiEndpoint.get("findText", FilePaths.findText, {
           query: FindTextQuery,
           query: FindTextQuery,
-          success: described(Schema.Array(Ripgrep.SearchMatch), "Matches"),
+          success: described(Schema.Array(LegacyMatch), "Matches"),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "find.text",
             identifier: "find.text",

+ 21 - 14
packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts

@@ -1,8 +1,7 @@
 import * as InstanceState from "@/effect/instance-state"
 import * as InstanceState from "@/effect/instance-state"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
-import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
@@ -15,7 +14,6 @@ import { InstanceHttpApi } from "../api"
 export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
 export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const ripgrep = yield* Ripgrep.Service
     const ripgrep = yield* Ripgrep.Service
-    const search = yield* Search.Service
     const locations = yield* LocationServiceMap
     const locations = yield* LocationServiceMap
 
 
     const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
     const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
@@ -26,8 +24,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
 
 
     const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
     const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
       return (yield* ripgrep
       return (yield* ripgrep
-        .search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
-        .pipe(Effect.orDie)).items
+        .grep({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
+        .pipe(Effect.orDie)).map((match) => ({
+        path: { text: match.entry.path },
+        lines: { text: match.text },
+        line_number: match.line,
+        absolute_offset: match.offset,
+        submatches: match.submatches.map((submatch) => ({
+          match: { text: submatch.text },
+          start: submatch.start,
+          end: submatch.end,
+        })),
+      }))
     })
     })
 
 
     const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
     const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
@@ -35,19 +43,18 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
     }) {
     }) {
       const directory = (yield* InstanceState.context).directory
       const directory = (yield* InstanceState.context).directory
       const limit = ctx.query.limit ?? 10
       const limit = ctx.query.limit ?? 10
-      const kind = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : "all")
+      const type = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined)
       const started = performance.now()
       const started = performance.now()
-      const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
+      const found = yield* filesystem(FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, limit, type })))
       yield* Effect.logInfo("find file", {
       yield* Effect.logInfo("find file", {
-        engine: "fff",
         query: ctx.query.query,
         query: ctx.query.query,
-        kind,
+        type,
         directory,
         directory,
         limit,
         limit,
-        results: fff.length,
+        results: found.length,
         duration: Math.round(performance.now() - started),
         duration: Math.round(performance.now() - started),
       })
       })
-      return fff.map((item) => item.path)
+      return found.map((item) => item.path)
     })
     })
 
 
     const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
     const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
@@ -73,10 +80,10 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
           return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
           return (yield* fs.list({ path: RelativePath.make(ctx.query.path) })).map((item) => ({
             name: path.basename(item.path),
             name: path.basename(item.path),
             path: item.path,
             path: item.path,
-            absolute: path.join(directory, item.path),
+            absolute: path.resolve(location.directory, item.path),
             type: item.type,
             type: item.type,
             ignored: ignored.ignores(
             ignored: ignored.ignores(
-              path.relative(location.project.directory, path.join(location.directory, item.path)) +
+              path.relative(location.project.directory, path.resolve(location.directory, item.path)) +
                 (item.type === "directory" ? "/" : ""),
                 (item.type === "directory" ? "/" : ""),
             ),
             ),
           }))
           }))
@@ -112,4 +119,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
       .handle("content", content)
       .handle("content", content)
       .handle("status", status)
       .handle("status", status)
   }),
   }),
-).pipe(Layer.provide(LocationServiceMap.layer), Layer.provide(Search.defaultLayer))
+).pipe(Layer.provide(LocationServiceMap.layer))

+ 2 - 2
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -17,7 +17,7 @@ import { BackgroundJob } from "@/background/job"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { Command } from "@/command"
 import { Command } from "@/command"
 import * as Observability from "@opencode-ai/core/observability"
 import * as Observability from "@opencode-ai/core/observability"
-import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Format } from "@/format"
 import { Format } from "@/format"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
@@ -234,7 +234,6 @@ export function createRoutes(
       Provider.defaultLayer,
       Provider.defaultLayer,
       PtyTicket.defaultLayer,
       PtyTicket.defaultLayer,
       Question.defaultLayer,
       Question.defaultLayer,
-      Ripgrep.defaultLayer,
       RuntimeFlags.defaultLayer,
       RuntimeFlags.defaultLayer,
       Session.defaultLayer,
       Session.defaultLayer,
       SessionCompaction.defaultLayer,
       SessionCompaction.defaultLayer,
@@ -259,6 +258,7 @@ export function createRoutes(
       HttpServer.layerServices,
       HttpServer.layerServices,
     ]),
     ]),
     Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
     Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
+    Layer.provideMerge(Ripgrep.defaultLayer),
     Layer.provide(InstanceLayer.layer),
     Layer.provide(InstanceLayer.layer),
     Layer.provideMerge(Observability.layer),
     Layer.provideMerge(Observability.layer),
   )
   )

+ 4 - 3
packages/opencode/src/tool/external-directory.ts

@@ -17,13 +17,13 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
   target?: string,
   target?: string,
   options?: Options,
   options?: Options,
 ) {
 ) {
-  if (!target) return
+  if (!target) return false
 
 
-  if (options?.bypass) return
+  if (options?.bypass) return false
 
 
   const ins = yield* InstanceState.context
   const ins = yield* InstanceState.context
   const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
   const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
-  if (containsPath(full, ins)) return
+  if (containsPath(full, ins)) return false
 
 
   const kind = options?.kind ?? "file"
   const kind = options?.kind ?? "file"
   const dir = kind === "directory" ? full : path.dirname(full)
   const dir = kind === "directory" ? full : path.dirname(full)
@@ -41,6 +41,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
       parentDir: dir,
       parentDir: dir,
     },
     },
   })
   })
+  return true
 })
 })
 
 
 export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
 export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {

+ 10 - 15
packages/opencode/src/tool/glob.ts

@@ -2,7 +2,7 @@ import path from "path"
 import { Effect, Schema } from "effect"
 import { Effect, Schema } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
 import DESCRIPTION from "./glob.txt"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
@@ -18,8 +18,7 @@ export const GlobTool = Tool.define(
   "glob",
   "glob",
   Effect.gen(function* () {
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
-    const searchSvc = yield* Search.Service
-
+    const ripgrep = yield* Ripgrep.Service
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
@@ -48,18 +47,14 @@ export const GlobTool = Tool.define(
           })
           })
 
 
           const limit = 100
           const limit = 100
-          const files = yield* searchSvc.glob({
-            cwd: search,
-            pattern: params.pattern,
-            limit,
-            signal: ctx.abort,
-          })
+          const files = yield* ripgrep.glob({ cwd: search, pattern: params.pattern, limit })
+          const truncated = files.length === limit
 
 
           const output = []
           const output = []
-          if (files.files.length === 0) output.push("No files found")
-          if (files.files.length > 0) {
-            output.push(...files.files)
-            if (files.truncated) {
+          if (files.length === 0) output.push("No files found")
+          if (files.length > 0) {
+            output.push(...files.map((file) => path.resolve(search, file.path)))
+            if (truncated) {
               output.push("")
               output.push("")
               output.push(
               output.push(
                 `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
                 `(Results are truncated: showing first ${limit} results. Consider using a more specific path or pattern.)`,
@@ -70,8 +65,8 @@ export const GlobTool = Tool.define(
           return {
           return {
             title: path.relative(ins.worktree, search),
             title: path.relative(ins.worktree, search),
             metadata: {
             metadata: {
-              count: files.files.length,
-              truncated: files.truncated,
+              count: files.length,
+              truncated,
             },
             },
             output: output.join("\n"),
             output: output.join("\n"),
           }
           }

+ 15 - 40
packages/opencode/src/tool/grep.ts

@@ -2,13 +2,11 @@ import path from "path"
 import { Effect, Schema } from "effect"
 import { Effect, Schema } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./grep.txt"
 import DESCRIPTION from "./grep.txt"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 
 
-const MAX_LINE_LENGTH = 2000
-
 export const Parameters = Schema.Struct({
 export const Parameters = Schema.Struct({
   pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
   pattern: Schema.String.annotate({ description: "The regex pattern to search for in file contents" }),
   path: Schema.optional(Schema.String).annotate({
   path: Schema.optional(Schema.String).annotate({
@@ -23,8 +21,7 @@ export const GrepTool = Tool.define(
   "grep",
   "grep",
   Effect.gen(function* () {
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
-    const searchSvc = yield* Search.Service
-
+    const ripgrep = yield* Ripgrep.Service
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
       parameters: Parameters,
       parameters: Parameters,
@@ -63,30 +60,27 @@ export const GrepTool = Tool.define(
           const search = FSUtil.resolve(requested)
           const search = FSUtil.resolve(requested)
           const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
           const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
           const cwd = info?.type === "Directory" ? search : path.dirname(search)
           const cwd = info?.type === "Directory" ? search : path.dirname(search)
-          const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
-
-          const result = yield* searchSvc.search({
+          const result = yield* ripgrep.grep({
             cwd,
             cwd,
             pattern: params.pattern,
             pattern: params.pattern,
-            glob: params.include ? [params.include] : undefined,
-            file,
-            signal: ctx.abort,
+            include: params.include,
+            limit: 100,
           })
           })
-          if (result.items.length === 0) return empty
+          if (result.length === 0) return empty
 
 
-          const rows = result.items.map((item) => ({
-            path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
-            line: item.line_number,
-            text: item.lines.text,
+          const rows = result.map((item) => ({
+            path: path.resolve(cwd, item.entry.path),
+            line: item.line,
+            text: item.text,
           }))
           }))
 
 
           const limit = 100
           const limit = 100
-          const truncated = rows.length > limit
-          const final = truncated ? rows.slice(0, limit) : rows
+          const truncated = rows.length === limit
+          const final = rows
           if (final.length === 0) return empty
           if (final.length === 0) return empty
 
 
           const total = rows.length
           const total = rows.length
-          const hasMore = truncated || result.hasNextPage
+          const hasMore = truncated || result.length === limit
           const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
           const output = [`Found ${total} matches${hasMore ? " (more matches available)" : ""}`]
 
 
           let current = ""
           let current = ""
@@ -96,31 +90,12 @@ export const GrepTool = Tool.define(
               current = match.path
               current = match.path
               output.push(`${match.path}:`)
               output.push(`${match.path}:`)
             }
             }
-            const text =
-              match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
-            output.push(`  Line ${match.line}: ${text}`)
+            output.push(`  Line ${match.line}: ${match.text}`)
           }
           }
 
 
           if (truncated) {
           if (truncated) {
             output.push("")
             output.push("")
-            output.push(
-              `(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
-            )
-          }
-
-          if (result.hasNextPage) {
-            output.push("")
-            output.push(`(Results truncated. Consider using a more specific path or pattern.)`)
-          }
-
-          if (result.partial) {
-            output.push("")
-            output.push("(Some paths were inaccessible and skipped)")
-          }
-
-          if (result.regexFallbackError) {
-            output.push("")
-            output.push(`(Regex fallback: ${result.regexFallbackError})`)
+            output.push("(Results truncated. Consider using a more specific path or pattern.)")
           }
           }
 
 
           return {
           return {

+ 1 - 4
packages/opencode/src/tool/read.ts

@@ -8,7 +8,6 @@ import DESCRIPTION from "./read.txt"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import { Instruction } from "../session/instruction"
 import { Instruction } from "../session/instruction"
-import { Search } from "@opencode-ai/core/filesystem/search"
 import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
 import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
 
 
 const DEFAULT_READ_LIMIT = 2000
 const DEFAULT_READ_LIMIT = 2000
@@ -65,14 +64,13 @@ type Metadata = {
 export const ReadTool = Tool.define<
 export const ReadTool = Tool.define<
   typeof Parameters,
   typeof Parameters,
   Metadata,
   Metadata,
-  FSUtil.Service | Instruction.Service | LSP.Service | Search.Service | Scope.Scope
+  FSUtil.Service | Instruction.Service | LSP.Service | Scope.Scope
 >(
 >(
   "read",
   "read",
   Effect.gen(function* () {
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const instruction = yield* Instruction.Service
     const instruction = yield* Instruction.Service
     const lsp = yield* LSP.Service
     const lsp = yield* LSP.Service
-    const search = yield* Search.Service
     const scope = yield* Scope.Scope
     const scope = yield* Scope.Scope
 
 
     const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
     const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) {
@@ -117,7 +115,6 @@ export const ReadTool = Tool.define<
     })
     })
 
 
     const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
     const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
-      yield* search.open({ file: filepath }).pipe(Effect.ignore)
       // LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
       // LSP warm-up is optional; do not let a background defect fail an otherwise successful read.
       yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
       yield* lsp.touchFile(filepath).pipe(Effect.ignoreCause, Effect.forkIn(scope))
     })
     })

+ 3 - 30
packages/opencode/src/tool/registry.ts

@@ -1,6 +1,6 @@
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
-import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { PlanExitTool } from "./plan"
 import { PlanExitTool } from "./plan"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
 import { QuestionTool } from "./question"
 import { QuestionTool } from "./question"
@@ -36,7 +36,6 @@ import { Effect, Layer, Context } from "effect"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Search } from "@opencode-ai/core/filesystem/search"
 import { Format } from "../format"
 import { Format } from "../format"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 import { EffectBridge } from "@/effect/bridge"
 import { EffectBridge } from "@/effect/bridge"
@@ -81,30 +80,7 @@ export interface Interface {
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
 
 
-export const layer: Layer.Layer<
-  Service,
-  never,
-  | Config.Service
-  | Plugin.Service
-  | Question.Service
-  | Todo.Service
-  | Agent.Service
-  | Skill.Service
-  | Session.Service
-  | BackgroundJob.Service
-  | Provider.Service
-  | LSP.Service
-  | Instruction.Service
-  | FSUtil.Service
-  | EventV2Bridge.Service
-  | HttpClient.HttpClient
-  | ChildProcessSpawner
-  | Search.Service
-  | Format.Service
-  | Truncate.Service
-  | RuntimeFlags.Service
-  | Database.Service
-> = Layer.effect(
+export const layer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
     const config = yield* Config.Service
     const config = yield* Config.Service
@@ -358,7 +334,6 @@ export const defaultLayer = Layer.suspend(() =>
       Layer.provide(FetchHttpClient.layer),
       Layer.provide(FetchHttpClient.layer),
       Layer.provide(Format.defaultLayer),
       Layer.provide(Format.defaultLayer),
       Layer.provide(CrossSpawnSpawner.defaultLayer),
       Layer.provide(CrossSpawnSpawner.defaultLayer),
-      Layer.provide(Search.defaultLayer),
       Layer.provide(Truncate.defaultLayer),
       Layer.provide(Truncate.defaultLayer),
     )
     )
     .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
     .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)),
@@ -440,7 +415,7 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
   return typeof value === "object" && value !== null && !Array.isArray(value)
   return typeof value === "object" && value !== null && !Array.isArray(value)
 }
 }
 
 
-export const node = LayerNode.make(layer, [
+export const node = LayerNode.make(layer.pipe(Layer.provide(Ripgrep.defaultLayer)), [
   Config.node,
   Config.node,
   Plugin.node,
   Plugin.node,
   Question.node,
   Question.node,
@@ -456,8 +431,6 @@ export const node = LayerNode.make(layer, [
   EventV2Bridge.node,
   EventV2Bridge.node,
   httpClient,
   httpClient,
   CrossSpawnSpawner.node,
   CrossSpawnSpawner.node,
-  Ripgrep.node,
-  Search.node,
   Format.node,
   Format.node,
   Truncate.node,
   Truncate.node,
   RuntimeFlags.node,
   RuntimeFlags.node,

+ 11 - 12
packages/opencode/src/tool/skill.ts

@@ -1,8 +1,7 @@
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
 import { Effect, Schema } from "effect"
 import { Effect, Schema } from "effect"
-import * as Stream from "effect/Stream"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Skill } from "../skill"
 import { Skill } from "../skill"
 import * as Tool from "./tool"
 import * as Tool from "./tool"
 import DESCRIPTION from "./skill.txt"
 import DESCRIPTION from "./skill.txt"
@@ -15,7 +14,7 @@ export const SkillTool = Tool.define(
   "skill",
   "skill",
   Effect.gen(function* () {
   Effect.gen(function* () {
     const skill = yield* Skill.Service
     const skill = yield* Skill.Service
-    const searchSvc = yield* Search.Service
+    const ripgrep = yield* Ripgrep.Service
 
 
     return {
     return {
       description: DESCRIPTION,
       description: DESCRIPTION,
@@ -35,14 +34,14 @@ export const SkillTool = Tool.define(
 
 
           const dir = path.dirname(info.location)
           const dir = path.dirname(info.location)
           const base = pathToFileURL(dir).href
           const base = pathToFileURL(dir).href
-          const limit = 10
-          const files = yield* searchSvc.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
-            Stream.filter((file) => !file.includes("SKILL.md")),
-            Stream.map((file) => path.resolve(dir, file)),
-            Stream.take(limit),
-            Stream.runCollect,
-            Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
-          )
+          const files = yield* ripgrep.find({
+            cwd: dir,
+            pattern: "!**/SKILL.md",
+            hidden: true,
+            follow: false,
+            signal: ctx.abort,
+            limit: 10,
+          })
 
 
           return {
           return {
             title: `Loaded skill: ${info.name}`,
             title: `Loaded skill: ${info.name}`,
@@ -57,7 +56,7 @@ export const SkillTool = Tool.define(
               "Note: file list is sampled.",
               "Note: file list is sampled.",
               "",
               "",
               "<skill_files>",
               "<skill_files>",
-              files,
+              files.map((file) => `<file>${path.resolve(dir, file.path)}</file>`).join("\n"),
               "</skill_files>",
               "</skill_files>",
               "</skill_content>",
               "</skill_content>",
             ].join("\n"),
             ].join("\n"),

+ 2 - 0
packages/opencode/test/control-plane/workspace.test.ts

@@ -33,6 +33,7 @@ import { Project } from "@/project/project"
 import { Vcs } from "@/project/vcs"
 import { Vcs } from "@/project/vcs"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2Bridge } from "@/event-v2-bridge"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 
 
 const originalEnv = {
 const originalEnv = {
   OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT,
   OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT,
@@ -54,6 +55,7 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(FSUtil.defaultLayer),
     Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
     Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
+    Layer.provide(Ripgrep.defaultLayer),
     Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
     Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))),
   )
   )
 
 

+ 4 - 1
packages/opencode/test/plugin/workspace-adapter.test.ts

@@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
@@ -55,7 +56,9 @@ const workspaceLayer = Workspace.layer.pipe(
   Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
   Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
   Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
   Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
 )
 )
-const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
+const it = testEffect(
+  Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
+)
 
 
 afterEach(async () => {
 afterEach(async () => {
   await disposeAllInstances()
   await disposeAllInstances()

+ 2 - 1
packages/opencode/test/server/httpapi-instance-context.test.ts

@@ -8,6 +8,7 @@ import { mkdir } from "node:fs/promises"
 import path from "node:path"
 import path from "node:path"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { WorkspaceV2 } from "@opencode-ai/core/workspace"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { Workspace } from "../../src/control-plane/workspace"
 import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
 import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
@@ -53,7 +54,7 @@ const it = testEffect(
     InstanceLayer.layer,
     InstanceLayer.layer,
     Project.defaultLayer,
     Project.defaultLayer,
     workspaceLayer,
     workspaceLayer,
-  ),
+  ).pipe(Layer.provide(Ripgrep.defaultLayer)),
 )
 )
 
 
 const instanceContextTestLayer = Layer.mergeAll(
 const instanceContextTestLayer = Layer.mergeAll(

+ 2 - 1
packages/opencode/test/server/httpapi-promptasync-context.test.ts

@@ -16,6 +16,7 @@ import * as Socket from "effect/unstable/socket/Socket"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
 import { mkdir } from "node:fs/promises"
 import { mkdir } from "node:fs/promises"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import { registerAdapter } from "../../src/control-plane/adapters"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { Workspace } from "../../src/control-plane/workspace"
 import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
 import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
@@ -58,7 +59,7 @@ const it = testEffect(
     InstanceLayer.layer,
     InstanceLayer.layer,
     Project.defaultLayer,
     Project.defaultLayer,
     workspaceLayer,
     workspaceLayer,
-  ),
+  ).pipe(Layer.provide(Ripgrep.defaultLayer)),
 )
 )
 
 
 const instanceContextTestLayer = Layer.mergeAll(
 const instanceContextTestLayer = Layer.mergeAll(

+ 2 - 1
packages/opencode/test/server/httpapi-session.test.ts

@@ -9,6 +9,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServ
 import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
 import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Flag } from "@opencode-ai/core/flag/flag"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import { registerAdapter } from "../../src/control-plane/adapters"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { Workspace } from "../../src/control-plane/workspace"
@@ -66,7 +67,7 @@ const it = testEffect(
     workspaceLayer,
     workspaceLayer,
     Database.defaultLayer,
     Database.defaultLayer,
     httpApiLayer,
     httpApiLayer,
-  ),
+  ).pipe(Layer.provide(Ripgrep.defaultLayer)),
 )
 )
 
 
 function pathFor(path: string, params: Record<string, string>) {
 function pathFor(path: string, params: Record<string, string>) {

+ 2 - 1
packages/opencode/test/server/httpapi-workspace-routing.test.ts

@@ -21,6 +21,7 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { Workspace } from "../../src/control-plane/workspace"
 import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Project } from "../../src/project/project"
 import { Project } from "../../src/project/project"
 import { Session } from "../../src/session/session"
 import { Session } from "../../src/session/session"
 import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
 import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
@@ -58,7 +59,7 @@ const it = testEffect(
     Project.defaultLayer,
     Project.defaultLayer,
     workspaceLayer,
     workspaceLayer,
     Socket.layerWebSocketConstructorGlobal,
     Socket.layerWebSocketConstructorGlobal,
-  ),
+  ).pipe(Layer.provide(Ripgrep.defaultLayer)),
 )
 )
 
 
 type ProxiedRequest = {
 type ProxiedRequest = {

+ 2 - 1
packages/opencode/test/server/httpapi-workspace.test.ts

@@ -11,6 +11,7 @@ import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/
 import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
 import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Server } from "../../src/server/server"
 import { Server } from "../../src/server/server"
 import { resetDatabase } from "../fixture/db"
 import { resetDatabase } from "../fixture/db"
 import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
 import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
@@ -34,7 +35,7 @@ const it = testEffect(
     InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
     InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
     Database.defaultLayer,
     Database.defaultLayer,
     httpApiLayer,
     httpApiLayer,
-  ),
+  ).pipe(Layer.provide(Ripgrep.defaultLayer)),
 )
 )
 
 
 function request(path: string, directory: string, init: RequestInit = {}) {
 function request(path: string, directory: string, init: RequestInit = {}) {

+ 2 - 2
packages/opencode/test/session/prompt.test.ts

@@ -48,7 +48,7 @@ import { Snapshot } from "../../src/snapshot"
 import { ToolRegistry } from "@/tool/registry"
 import { ToolRegistry } from "@/tool/registry"
 import { Truncate } from "@/tool/truncate"
 import { Truncate } from "@/tool/truncate"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Format } from "../../src/format"
 import { Format } from "../../src/format"
 import { TestInstance } from "../fixture/fixture"
 import { TestInstance } from "../fixture/fixture"
 import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
 import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
@@ -190,7 +190,7 @@ function makePrompt(input?: { processor?: "blocking" }) {
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(Git.defaultLayer),
     Layer.provide(Git.defaultLayer),
-    Layer.provide(Search.defaultLayer),
+    Layer.provide(Ripgrep.defaultLayer),
     Layer.provide(Format.defaultLayer),
     Layer.provide(Format.defaultLayer),
     Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
     Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
     Layer.provideMerge(todo),
     Layer.provideMerge(todo),

+ 2 - 2
packages/opencode/test/session/snapshot-tool-race.test.ts

@@ -57,7 +57,7 @@ import { ToolRegistry } from "@/tool/registry"
 import { Truncate } from "@/tool/truncate"
 import { Truncate } from "@/tool/truncate"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Format } from "../../src/format"
 import { Format } from "../../src/format"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 
 
@@ -135,7 +135,7 @@ function makeHttp() {
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(FetchHttpClient.layer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(Git.defaultLayer),
     Layer.provide(Git.defaultLayer),
-    Layer.provide(Search.defaultLayer),
+    Layer.provide(Ripgrep.defaultLayer),
     Layer.provide(Format.defaultLayer),
     Layer.provide(Format.defaultLayer),
     Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
     Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
     Layer.provideMerge(todo),
     Layer.provideMerge(todo),

+ 4 - 1
packages/opencode/test/session/structured-output-integration.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Effect, Layer } from "effect"
 import { Effect, Layer } from "effect"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
 import { SessionPrompt } from "../../src/session/prompt"
 import { SessionPrompt } from "../../src/session/prompt"
@@ -8,7 +9,9 @@ import { testEffect } from "../lib/effect"
 
 
 // Skip tests if no API key is available
 // Skip tests if no API key is available
 const hasApiKey = !!process.env.ANTHROPIC_API_KEY
 const hasApiKey = !!process.env.ANTHROPIC_API_KEY
-const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))
+const it = testEffect(
+  Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)),
+)
 const live = hasApiKey ? it.instance : it.instance.skip
 const live = hasApiKey ? it.instance : it.instance.skip
 
 
 describe("StructuredOutput Integration", () => {
 describe("StructuredOutput Integration", () => {

+ 2 - 2
packages/opencode/test/tool/glob.test.ts

@@ -5,7 +5,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
 import { GlobTool } from "../../src/tool/glob"
 import { GlobTool } from "../../src/tool/glob"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
 import { Truncate } from "@/tool/truncate"
 import { Truncate } from "@/tool/truncate"
@@ -23,7 +23,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
   Layer.mergeAll(
   Layer.mergeAll(
     CrossSpawnSpawner.defaultLayer,
     CrossSpawnSpawner.defaultLayer,
     FSUtil.defaultLayer,
     FSUtil.defaultLayer,
-    Search.defaultLayer,
+    Ripgrep.defaultLayer,
     Truncate.defaultLayer,
     Truncate.defaultLayer,
     Agent.defaultLayer,
     Agent.defaultLayer,
     Git.defaultLayer,
     Git.defaultLayer,

+ 21 - 2
packages/opencode/test/tool/grep.test.ts

@@ -11,7 +11,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
 import { Truncate } from "@/tool/truncate"
 import { Truncate } from "@/tool/truncate"
 import { Agent } from "../../src/agent/agent"
 import { Agent } from "../../src/agent/agent"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
 import { Permission } from "../../src/permission"
 import { Permission } from "../../src/permission"
@@ -25,7 +25,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
   Layer.mergeAll(
   Layer.mergeAll(
     CrossSpawnSpawner.defaultLayer,
     CrossSpawnSpawner.defaultLayer,
     FSUtil.defaultLayer,
     FSUtil.defaultLayer,
-    Search.defaultLayer,
+    Ripgrep.defaultLayer,
     Truncate.defaultLayer,
     Truncate.defaultLayer,
     Agent.defaultLayer,
     Agent.defaultLayer,
     Git.defaultLayer,
     Git.defaultLayer,
@@ -135,6 +135,25 @@ describe("tool.grep", () => {
     }),
     }),
   )
   )
 
 
+  it.instance("does not report an unknown total when results are truncated", () =>
+    Effect.gen(function* () {
+      const test = yield* TestInstance
+      yield* Effect.promise(() =>
+        Promise.all(
+          Array.from({ length: 101 }, (_, index) =>
+            Bun.write(path.join(test.directory, `match-${index}.txt`), "needle"),
+          ),
+        ),
+      )
+      const info = yield* GrepTool
+      const grep = yield* info.init()
+      const result = yield* grep.execute({ pattern: "needle", path: test.directory, include: "*.txt" }, ctx)
+
+      expect(result.output).toContain("(Results truncated. Consider using a more specific path or pattern.)")
+      expect(result.output).not.toMatch(/showing \d+ of \d+ matches/)
+    }),
+  )
+
   it.instance("supports exact file paths", () =>
   it.instance("supports exact file paths", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       const test = yield* TestInstance
       const test = yield* TestInstance

+ 2 - 2
packages/opencode/test/tool/read.test.ts

@@ -8,7 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { Global } from "@opencode-ai/core/global"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { RuntimeFlags } from "@/effect/runtime-flags"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { LSP } from "@/lsp/lsp"
 import { LSP } from "@/lsp/lsp"
 import { Permission } from "../../src/permission"
 import { Permission } from "../../src/permission"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { SessionID, MessageID } from "../../src/session/schema"
@@ -50,7 +50,7 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
     CrossSpawnSpawner.defaultLayer,
     CrossSpawnSpawner.defaultLayer,
     Instruction.defaultLayer,
     Instruction.defaultLayer,
     LSP.defaultLayer,
     LSP.defaultLayer,
-    Search.defaultLayer,
+    Ripgrep.defaultLayer,
     Truncate.defaultLayer,
     Truncate.defaultLayer,
   )
   )
 
 

+ 2 - 2
packages/opencode/test/tool/registry.test.ts

@@ -26,7 +26,7 @@ import { Instruction } from "@/session/instruction"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { FetchHttpClient } from "effect/unstable/http"
 import { FetchHttpClient } from "effect/unstable/http"
 import { Format } from "@/format"
 import { Format } from "@/format"
-import { Search } from "@opencode-ai/core/filesystem/search"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import * as Truncate from "@/tool/truncate"
 import * as Truncate from "@/tool/truncate"
 import { InstanceState } from "@/effect/instance-state"
 import { InstanceState } from "@/effect/instance-state"
 
 
@@ -66,7 +66,7 @@ const registryLayer = (opts: RegistryLayerOptions = {}) =>
       Layer.provide(FetchHttpClient.layer),
       Layer.provide(FetchHttpClient.layer),
       Layer.provide(Format.defaultLayer),
       Layer.provide(Format.defaultLayer),
       Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
       Layer.provide(Layer.mergeAll(node, Database.defaultLayer)),
-      Layer.provide(Search.defaultLayer),
+      Layer.provide(Ripgrep.defaultLayer),
       Layer.provide(Truncate.defaultLayer),
       Layer.provide(Truncate.defaultLayer),
     )
     )
     .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))
     .pipe(Layer.provide(RuntimeFlags.layer(opts.flags ?? {})))

+ 2 - 1
packages/opencode/test/tool/skill.test.ts

@@ -1,5 +1,6 @@
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Cause, Effect, Exit, Layer } from "effect"
 import { Cause, Effect, Exit, Layer } from "effect"
 import { afterEach, describe, expect } from "bun:test"
 import { afterEach, describe, expect } from "bun:test"
 import path from "path"
 import path from "path"
@@ -28,7 +29,7 @@ afterEach(async () => {
 
 
 const node = CrossSpawnSpawner.defaultLayer
 const node = CrossSpawnSpawner.defaultLayer
 
 
-const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
+const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node).pipe(Layer.provide(Ripgrep.defaultLayer)))
 
 
 describe("tool.skill", () => {
 describe("tool.skill", () => {
   it.instance("execute returns skill content block with files", () =>
   it.instance("execute returns skill content block with files", () =>

+ 2 - 1
packages/opencode/test/tool/task.test.ts

@@ -7,6 +7,7 @@ import { BackgroundJob } from "@/background/job"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { Config } from "@/config/config"
 import { Config } from "@/config/config"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { Ripgrep } from "@opencode-ai/core/ripgrep"
 import { Session } from "@/session/session"
 import { Session } from "@/session/session"
 import type { SessionPrompt } from "../../src/session/prompt"
 import type { SessionPrompt } from "../../src/session/prompt"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -45,7 +46,7 @@ const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
     ToolRegistry.defaultLayer,
     ToolRegistry.defaultLayer,
     Database.defaultLayer,
     Database.defaultLayer,
     RuntimeFlags.layer(flags),
     RuntimeFlags.layer(flags),
-  )
+  ).pipe(Layer.provide(Ripgrep.defaultLayer))
 
 
 const it = testEffect(layer())
 const it = testEffect(layer())
 const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
 const background = testEffect(layer({ experimentalBackgroundSubagents: true }))

+ 27 - 6
packages/server/src/handlers/fs.ts

@@ -5,10 +5,31 @@ import { Api } from "../api"
 import { response } from "../groups/location"
 import { response } from "../groups/location"
 
 
 export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
 export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
-  Effect.succeed(
-    handlers
-      .handle("fs.read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))
-      .handle("fs.list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query))))
-      .handle("fs.find", (ctx) => response(FileSystem.Service.use((fs) => fs.find(ctx.query)))),
-  ),
+  Effect.gen(function* () {
+    return handlers
+      .handle("fs.read", (ctx) =>
+        response(
+          Effect.gen(function* () {
+            const fs = yield* FileSystem.Service
+            return yield* fs.read(ctx.query)
+          }),
+        ),
+      )
+      .handle("fs.list", (ctx) =>
+        response(
+          Effect.gen(function* () {
+            const fs = yield* FileSystem.Service
+            return yield* fs.list(ctx.query)
+          }),
+        ),
+      )
+      .handle("fs.find", (ctx) =>
+        response(
+          Effect.gen(function* () {
+            const fs = yield* FileSystem.Service
+            return yield* fs.find(ctx.query)
+          }),
+        ),
+      )
+  }),
 )
 )

+ 1 - 0
packages/tui/src/component/prompt/autocomplete.tsx

@@ -318,6 +318,7 @@ export function Autocomplete(props: {
       // Get files from SDK
       // Get files from SDK
       const result = await sdk.client.v2.fs.find({
       const result = await sdk.client.v2.fs.find({
         query: baseQuery,
         query: baseQuery,
+        limit: "20",
         location: { workspace: project.workspace.current() },
         location: { workspace: project.workspace.current() },
       })
       })
 
 

+ 31 - 0
patches/@ff-labs%2Ffff-bun@0.9.3.patch

@@ -0,0 +1,31 @@
+diff --git a/src/download.ts b/src/download.ts
+index 3454256..6dca25a 100644
+--- a/src/download.ts
++++ b/src/download.ts
+@@ -7,7 +7,7 @@
+  */
+ 
++declare const FFF_LIBC: "gnu" | "musl";
+ import { existsSync } from "node:fs";
+-import { createRequire } from "node:module";
+ import { dirname, join } from "node:path";
+ import { fileURLToPath } from "node:url";
+ import { getLibFilename, getNpmPackageName } from "./platform";
+@@ -54,14 +54,10 @@ export function binaryExists(): boolean {
+  * in the same directory.
+  */
+ function resolveFromNpmPackage(): string | null {
+-  const packageName = getNpmPackageName();
+-
+   try {
+-    // Use createRequire to resolve the platform package's location
+-    const require = createRequire(join(getPackageDir(), "package.json"));
+-    const packageJsonPath = require.resolve(`${packageName}/package.json`);
+-    const packageDir = dirname(packageJsonPath);
+-    const binaryPath = join(packageDir, getLibFilename());
++    const binaryPath = require(
++      `@ff-labs/fff-bin-${process.platform === "linux" ? `linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : getNpmPackageName().endsWith("musl") ? "musl" : "gnu"}` : `${process.platform}-${process.arch}`}/${process.platform === "win32" ? "fff_c.dll" : process.platform === "darwin" ? "libfff_c.dylib" : "libfff_c.so"}`,
++    );
+ 
+     if (existsSync(binaryPath)) {
+       return binaryPath;