Преглед изворни кода

refactor(ripgrep): use embedded wasm backend (#21703)

Shoubhit Dash пре 3 дана
родитељ
комит
d6840868d4

+ 3 - 0
bun.lock

@@ -396,6 +396,7 @@
         "opentui-spinner": "0.0.6",
         "partial-json": "0.1.7",
         "remeda": "catalog:",
+        "ripgrep": "0.3.1",
         "semver": "^7.6.3",
         "solid-js": "catalog:",
         "strip-ansi": "7.1.2",
@@ -4345,6 +4346,8 @@
 
     "rimraf": ["[email protected]", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
 
+    "ripgrep": ["[email protected]", "", { "bin": { "rg": "lib/rg.mjs", "ripgrep": "lib/rg.mjs" } }, "sha512-6bDtNIBh1qPviVIU685/4uv0Ap5t8eS4wiJhy/tR2LdIeIey9CVasENlGS+ul3HnTmGANIp7AjnfsztsRmALfQ=="],
+
     "roarr": ["[email protected]", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
 
     "rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],

+ 1 - 0
packages/opencode/package.json

@@ -153,6 +153,7 @@
     "opentui-spinner": "0.0.6",
     "partial-json": "0.1.7",
     "remeda": "catalog:",
+    "ripgrep": "0.3.1",
     "semver": "^7.6.3",
     "solid-js": "catalog:",
     "strip-ansi": "7.1.2",

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

@@ -46,7 +46,7 @@ const FilesCommand = cmd({
   async handler(args) {
     await bootstrap(process.cwd(), async () => {
       const files: string[] = []
-      for await (const file of Ripgrep.files({
+      for await (const file of await Ripgrep.files({
         cwd: Instance.directory,
         glob: args.glob ? [args.glob] : undefined,
       })) {

+ 34 - 2
packages/opencode/src/file/index.ts

@@ -1,8 +1,10 @@
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect/instance-state"
+import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@/filesystem"
 import { Git } from "@/git"
 import { Effect, Layer, Context } from "effect"
+import * as Stream from "effect/Stream"
 import { formatPatch, structuredPatch } from "diff"
 import fuzzysort from "fuzzysort"
 import ignore from "ignore"
@@ -342,6 +344,7 @@ export namespace File {
     Service,
     Effect.gen(function* () {
       const appFs = yield* AppFileSystem.Service
+      const rg = yield* Ripgrep.Service
       const git = yield* Git.Service
 
       const state = yield* InstanceState.make<State>(
@@ -381,7 +384,10 @@ export namespace File {
 
           next.dirs = Array.from(dirs).toSorted()
         } else {
-          const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
+          const files = yield* rg.files({ cwd: Instance.directory }).pipe(
+            Stream.runCollect,
+            Effect.map((chunk) => [...chunk]),
+          )
           const seen = new Set<string>()
           for (const file of files) {
             next.files.push(file)
@@ -642,5 +648,31 @@ export namespace File {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Ripgrep.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Git.defaultLayer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  export function init() {
+    return runPromise((svc) => svc.init())
+  }
+
+  export async function status() {
+    return runPromise((svc) => svc.status())
+  }
+
+  export async function read(file: string): Promise<Content> {
+    return runPromise((svc) => svc.read(file))
+  }
+
+  export async function list(dir?: string) {
+    return runPromise((svc) => svc.list(dir))
+  }
+
+  export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
+    return runPromise((svc) => svc.search(input))
+  }
 }

+ 444 - 390
packages/opencode/src/file/ripgrep.ts

@@ -1,28 +1,16 @@
-// Ripgrep utility functions
-import path from "path"
-import { Global } from "../global"
 import fs from "fs/promises"
+import path from "path"
+import { fileURLToPath } from "url"
 import z from "zod"
-import { Effect, Layer, Context, Schema } from "effect"
-import * as Stream from "effect/Stream"
-import { ChildProcess } from "effect/unstable/process"
-import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
-import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-import type { PlatformError } from "effect/PlatformError"
-import { NamedError } from "@opencode-ai/util/error"
-import { lazy } from "../util/lazy"
-
-import { Filesystem } from "../util/filesystem"
-import { AppFileSystem } from "../filesystem"
-import { Process } from "../util/process"
-import { which } from "../util/which"
-import { text } from "node:stream/consumers"
-
-import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
+import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
+import { ripgrep } from "ripgrep"
+import { makeRuntime } from "@/effect/run-service"
+import { Filesystem } from "@/util/filesystem"
 import { Log } from "@/util/log"
 
 export namespace Ripgrep {
   const log = Log.create({ service: "ripgrep" })
+
   const Stats = z.object({
     elapsed: z.object({
       secs: z.number(),
@@ -94,437 +82,503 @@ export namespace Ripgrep {
 
   const Result = z.union([Begin, Match, End, Summary])
 
-  const Hit = Schema.Struct({
-    type: Schema.Literal("match"),
-    data: Schema.Struct({
-      path: Schema.Struct({
-        text: Schema.String,
-      }),
-      lines: Schema.Struct({
-        text: Schema.String,
-      }),
-      line_number: Schema.Number,
-      absolute_offset: Schema.Number,
-      submatches: Schema.mutable(
-        Schema.Array(
-          Schema.Struct({
-            match: Schema.Struct({
-              text: Schema.String,
-            }),
-            start: Schema.Number,
-            end: Schema.Number,
-          }),
-        ),
-      ),
-    }),
-  })
-
-  const Row = Schema.Union([
-    Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
-    Hit,
-    Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
-    Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
-  ])
-
-  const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
-
   export type Result = z.infer<typeof Result>
   export type Match = z.infer<typeof Match>
   export type Item = Match["data"]
   export type Begin = z.infer<typeof Begin>
   export type End = z.infer<typeof End>
   export type Summary = z.infer<typeof Summary>
-  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" },
-    "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
-  } as const
-
-  export const ExtractionFailedError = NamedError.create(
-    "RipgrepExtractionFailedError",
-    z.object({
-      filepath: z.string(),
-      stderr: z.string(),
-    }),
-  )
+  export type Row = Match["data"]
 
-  export const UnsupportedPlatformError = NamedError.create(
-    "RipgrepUnsupportedPlatformError",
-    z.object({
-      platform: z.string(),
-    }),
-  )
+  export interface SearchResult {
+    items: Item[]
+    partial: boolean
+  }
 
-  export const DownloadFailedError = NamedError.create(
-    "RipgrepDownloadFailedError",
-    z.object({
-      url: z.string(),
-      status: z.number(),
-    }),
-  )
+  export interface FilesInput {
+    cwd: string
+    glob?: string[]
+    hidden?: boolean
+    follow?: boolean
+    maxDepth?: number
+    signal?: AbortSignal
+  }
 
-  const state = lazy(async () => {
-    const system = which("rg")
-    if (system) {
-      const stat = await fs.stat(system).catch(() => undefined)
-      if (stat?.isFile()) return { filepath: system }
-      log.warn("bun.which returned invalid rg path", { filepath: system })
-    }
-    const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
-
-    if (!(await Filesystem.exists(filepath))) {
-      const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
-      const config = PLATFORM[platformKey]
-      if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
-
-      const version = "14.1.1"
-      const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
-      const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
-
-      const response = await fetch(url)
-      if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
-
-      const arrayBuffer = await response.arrayBuffer()
-      const archivePath = path.join(Global.Path.bin, filename)
-      await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
-      if (config.extension === "tar.gz") {
-        const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
-
-        if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
-        if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
-
-        const proc = Process.spawn(args, {
-          cwd: Global.Path.bin,
-          stderr: "pipe",
-          stdout: "pipe",
-        })
-        const exit = await proc.exited
-        if (exit !== 0) {
-          const stderr = proc.stderr ? await text(proc.stderr) : ""
-          throw new ExtractionFailedError({
-            filepath,
-            stderr,
-          })
-        }
-      }
-      if (config.extension === "zip") {
-        const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
-        const entries = await zipFileReader.getEntries()
-        let rgEntry: any
-        for (const entry of entries) {
-          if (entry.filename.endsWith("rg.exe")) {
-            rgEntry = entry
-            break
-          }
-        }
+  export interface SearchInput {
+    cwd: string
+    pattern: string
+    glob?: string[]
+    limit?: number
+    follow?: boolean
+    file?: string[]
+    signal?: AbortSignal
+  }
 
-        if (!rgEntry) {
-          throw new ExtractionFailedError({
-            filepath: archivePath,
-            stderr: "rg.exe not found in zip archive",
-          })
-        }
+  export interface TreeInput {
+    cwd: string
+    limit?: number
+    signal?: AbortSignal
+  }
 
-        const rgBlob = await rgEntry.getData(new BlobWriter())
-        if (!rgBlob) {
-          throw new ExtractionFailedError({
-            filepath: archivePath,
-            stderr: "Failed to extract rg.exe from zip archive",
-          })
-        }
-        await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
-        await zipFileReader.close()
-      }
-      await fs.unlink(archivePath)
-      if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
+  export interface Interface {
+    readonly files: (input: FilesInput) => Stream.Stream<string, Error>
+    readonly tree: (input: TreeInput) => Effect.Effect<string, Error>
+    readonly search: (input: SearchInput) => Effect.Effect<SearchResult, Error>
+  }
+
+  export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
+
+  type Run = { kind: "files" | "search"; cwd: string; args: string[] }
+
+  type WorkerResult = {
+    type: "result"
+    code: number
+    stdout: string
+    stderr: string
+  }
+
+  type WorkerLine = {
+    type: "line"
+    line: string
+  }
+
+  type WorkerDone = {
+    type: "done"
+    code: number
+    stderr: string
+  }
+
+  type WorkerError = {
+    type: "error"
+    error: {
+      message: string
+      name?: string
+      stack?: string
     }
+  }
+
+  function env() {
+    const env = Object.fromEntries(
+      Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
+    )
+    delete env.RIPGREP_CONFIG_PATH
+    return env
+  }
+
+  function text(input: unknown) {
+    if (typeof input === "string") return input
+    if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
+    if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
+    return String(input)
+  }
+
+  function toError(input: unknown) {
+    if (input instanceof Error) return input
+    if (typeof input === "string") return new Error(input)
+    return new Error(String(input))
+  }
+
+  function abort(signal?: AbortSignal) {
+    const err = signal?.reason
+    if (err instanceof Error) return err
+    const out = new Error("Aborted")
+    out.name = "AbortError"
+    return out
+  }
+
+  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 {
-      filepath,
+      ...data,
+      path: {
+        ...data.path,
+        text: clean(data.path.text),
+      },
     }
-  })
+  }
 
-  export async function filepath() {
-    const { filepath } = await state()
-    return filepath
+  function opts(cwd: string) {
+    return {
+      env: env(),
+      preopens: { ".": cwd },
+    }
   }
 
-  export async function* files(input: {
-    cwd: string
-    glob?: string[]
-    hidden?: boolean
-    follow?: boolean
-    maxDepth?: number
-    signal?: AbortSignal
-  }) {
-    input.signal?.throwIfAborted()
+  function check(cwd: string) {
+    return Effect.tryPromise({
+      try: () => fs.stat(cwd).catch(() => undefined),
+      catch: toError,
+    }).pipe(
+      Effect.flatMap((stat) =>
+        stat?.isDirectory()
+          ? Effect.void
+          : Effect.fail(
+              Object.assign(new Error(`No such file or directory: '${cwd}'`), {
+                code: "ENOENT",
+                errno: -2,
+                path: cwd,
+              }),
+            ),
+      ),
+    )
+  }
 
-    const args = [await filepath(), "--files", "--glob=!.git/*"]
+  function filesArgs(input: FilesInput) {
+    const args = ["--files", "--glob=!.git/*"]
     if (input.follow) args.push("--follow")
     if (input.hidden !== false) args.push("--hidden")
     if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
     if (input.glob) {
-      for (const g of input.glob) {
-        args.push(`--glob=${g}`)
+      for (const glob of input.glob) {
+        args.push(`--glob=${glob}`)
       }
     }
+    args.push(".")
+    return args
+  }
 
-    // Guard against invalid cwd to provide a consistent ENOENT error.
-    if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
-      throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
-        code: "ENOENT",
-        errno: -2,
-        path: input.cwd,
-      })
+  function searchArgs(input: SearchInput) {
+    const args = ["--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
+  }
 
-    const proc = Process.spawn(args, {
-      cwd: input.cwd,
-      stdout: "pipe",
-      stderr: "ignore",
-      abort: input.signal,
-    })
-
-    if (!proc.stdout) {
-      throw new Error("Process output not available")
-    }
+  function parse(stdout: string) {
+    return stdout
+      .trim()
+      .split(/\r?\n/)
+      .filter(Boolean)
+      .map((line) => Result.parse(JSON.parse(line)))
+      .flatMap((item) => (item.type === "match" ? [row(item.data)] : []))
+  }
 
-    let buffer = ""
-    const stream = proc.stdout as AsyncIterable<Buffer | string>
-    for await (const chunk of stream) {
-      input.signal?.throwIfAborted()
+  function target() {
+    const js = new URL("./ripgrep.worker.js", import.meta.url)
+    return Effect.tryPromise({
+      try: () => Filesystem.exists(fileURLToPath(js)),
+      catch: toError,
+    }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url))))
+  }
 
-      buffer += typeof chunk === "string" ? chunk : chunk.toString()
-      // Handle both Unix (\n) and Windows (\r\n) line endings
-      const lines = buffer.split(/\r?\n/)
-      buffer = lines.pop() || ""
+  function worker() {
+    return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
+  }
 
-      for (const line of lines) {
-        if (line) yield line
-      }
+  function drain(buf: string, chunk: unknown, push: (line: string) => void) {
+    const lines = (buf + text(chunk)).split(/\r?\n/)
+    buf = lines.pop() || ""
+    for (const line of lines) {
+      if (line) push(line)
     }
+    return buf
+  }
 
-    if (buffer) yield buffer
-    await proc.exited
-
-    input.signal?.throwIfAborted()
+  function fail(queue: Queue.Queue<string, Error | Cause.Done>, err: Error) {
+    Queue.failCauseUnsafe(queue, Cause.fail(err))
   }
 
-  export interface Interface {
-    readonly files: (input: {
-      cwd: string
-      glob?: string[]
-      hidden?: boolean
-      follow?: boolean
-      maxDepth?: number
-    }) => Stream.Stream<string, PlatformError>
-    readonly search: (input: {
-      cwd: string
-      pattern: string
-      glob?: string[]
-      limit?: number
-      follow?: boolean
-      file?: string[]
-    }) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
+  function searchDirect(input: SearchInput) {
+    return Effect.tryPromise({
+      try: () =>
+        ripgrep(searchArgs(input), {
+          buffer: true,
+          ...opts(input.cwd),
+        }),
+      catch: toError,
+    }).pipe(
+      Effect.flatMap((ret) => {
+        const out = ret.stdout ?? ""
+        if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) {
+          return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1))
+        }
+        return Effect.sync(() => ({
+          items: ret.code === 1 ? [] : parse(out),
+          partial: ret.code === 2,
+        }))
+      }),
+    )
   }
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
+  function searchWorker(input: SearchInput) {
+    if (input.signal?.aborted) return Effect.fail(abort(input.signal))
+
+    return Effect.acquireUseRelease(
+      worker(),
+      (w) =>
+        Effect.callback<SearchResult, Error>((resume, signal) => {
+          let open = true
+          const done = (effect: Effect.Effect<SearchResult, Error>) => {
+            if (!open) return
+            open = false
+            resume(effect)
+          }
+          const onabort = () => done(Effect.fail(abort(input.signal)))
 
-  export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const spawner = yield* ChildProcessSpawner
-      const afs = yield* AppFileSystem.Service
-      const bin = Effect.fn("Ripgrep.path")(function* () {
-        return yield* Effect.promise(() => filepath())
-      })
-      const args = Effect.fn("Ripgrep.args")(function* (input: {
-        mode: "files" | "search"
-        glob?: string[]
-        hidden?: boolean
-        follow?: boolean
-        maxDepth?: number
-        limit?: number
-        pattern?: string
-        file?: string[]
-      }) {
-        const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
-        if (input.follow) out.push("--follow")
-        if (input.hidden !== false) out.push("--hidden")
-        if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
-        if (input.glob) {
-          for (const g of input.glob) {
-            out.push(`--glob=${g}`)
+          w.onerror = (evt) => {
+            done(Effect.fail(toError(evt.error ?? evt.message)))
+          }
+          w.onmessage = (evt: MessageEvent<WorkerResult | WorkerError>) => {
+            const msg = evt.data
+            if (msg.type === "error") {
+              done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error)))
+              return
+            }
+            if (msg.code === 1) {
+              done(Effect.succeed({ items: [], partial: false }))
+              return
+            }
+            if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) {
+              done(Effect.fail(error(msg.stderr, msg.code)))
+              return
+            }
+            done(
+              Effect.sync(() => ({
+                items: parse(msg.stdout),
+                partial: msg.code === 2,
+              })),
+            )
           }
-        }
-        if (input.limit) out.push(`--max-count=${input.limit}`)
-        if (input.mode === "search") out.push("--no-messages")
-        if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
-        return out
-      })
 
-      const files = Effect.fn("Ripgrep.files")(function* (input: {
-        cwd: string
-        glob?: string[]
-        hidden?: boolean
-        follow?: boolean
-        maxDepth?: number
-      }) {
-        const rgPath = yield* bin()
-        const isDir = yield* afs.isDir(input.cwd)
-        if (!isDir) {
-          return yield* Effect.die(
-            Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
-              code: "ENOENT" as const,
-              errno: -2,
-              path: input.cwd,
-            }),
-          )
+          input.signal?.addEventListener("abort", onabort, { once: true })
+          signal.addEventListener("abort", onabort, { once: true })
+          w.postMessage({
+            kind: "search",
+            cwd: input.cwd,
+            args: searchArgs(input),
+          } satisfies Run)
+
+          return Effect.sync(() => {
+            input.signal?.removeEventListener("abort", onabort)
+            signal.removeEventListener("abort", onabort)
+            w.onerror = null
+            w.onmessage = null
+          })
+        }),
+      (w) => Effect.sync(() => w.terminate()),
+    )
+  }
+
+  function filesDirect(input: FilesInput) {
+    return Stream.callback<string, Error>(
+      Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
+        let buf = ""
+        let err = ""
+
+        const out = {
+          write(chunk: unknown) {
+            buf = drain(buf, chunk, (line) => {
+              Queue.offerUnsafe(queue, clean(line))
+            })
+          },
         }
 
-        const cmd = yield* args({
-          mode: "files",
-          glob: input.glob,
-          hidden: input.hidden,
-          follow: input.follow,
-          maxDepth: input.maxDepth,
-        })
-
-        return spawner
-          .streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
-          .pipe(Stream.filter((line: string) => line.length > 0))
-      })
+        const stderr = {
+          write(chunk: unknown) {
+            err += text(chunk)
+          },
+        }
 
-      const search = Effect.fn("Ripgrep.search")(function* (input: {
-        cwd: string
-        pattern: string
-        glob?: string[]
-        limit?: number
-        follow?: boolean
-        file?: string[]
-      }) {
-        return yield* Effect.scoped(
+        yield* Effect.forkScoped(
           Effect.gen(function* () {
-            const cmd = yield* args({
-              mode: "search",
-              glob: input.glob,
-              follow: input.follow,
-              limit: input.limit,
-              pattern: input.pattern,
-              file: input.file,
+            yield* check(input.cwd)
+            const ret = yield* Effect.tryPromise({
+              try: () =>
+                ripgrep(filesArgs(input), {
+                  stdout: out,
+                  stderr,
+                  ...opts(input.cwd),
+                }),
+              catch: toError,
             })
-
-            const handle = yield* spawner.spawn(
-              ChildProcess.make(cmd[0], cmd.slice(1), {
-                cwd: input.cwd,
-                stdin: "ignore",
+            if (buf) Queue.offerUnsafe(queue, clean(buf))
+            if (ret.code === 0 || ret.code === 1) {
+              Queue.endUnsafe(queue)
+              return
+            }
+            fail(queue, error(err, ret.code ?? 1))
+          }).pipe(
+            Effect.catch((err) =>
+              Effect.sync(() => {
+                fail(queue, err)
               }),
-            )
+            ),
+          ),
+        )
+      }),
+    )
+  }
 
-            const [items, stderr, code] = yield* Effect.all(
-              [
-                Stream.decodeText(handle.stdout).pipe(
-                  Stream.splitLines,
-                  Stream.filter((line) => line.length > 0),
-                  Stream.mapEffect((line) =>
-                    decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
-                  ),
-                  Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
-                  Stream.map((row): Item => row.data),
-                  Stream.runCollect,
-                  Effect.map((chunk) => [...chunk]),
-                ),
-                Stream.mkString(Stream.decodeText(handle.stderr)),
-                handle.exitCode,
-              ],
-              { concurrency: "unbounded" },
-            )
+  function filesWorker(input: FilesInput) {
+    return Stream.callback<string, Error>(
+      Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
+        if (input.signal?.aborted) {
+          fail(queue, abort(input.signal))
+          return
+        }
 
-            if (code !== 0 && code !== 1 && code !== 2) {
-              return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
-            }
+        const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate()))
+        let open = true
+        const close = () => {
+          if (!open) return false
+          open = false
+          return true
+        }
+        const onabort = () => {
+          if (!close()) return
+          fail(queue, abort(input.signal))
+        }
 
-            return {
-              items,
-              partial: code === 2,
-            }
+        w.onerror = (evt) => {
+          if (!close()) return
+          fail(queue, toError(evt.error ?? evt.message))
+        }
+        w.onmessage = (evt: MessageEvent<WorkerLine | WorkerDone | WorkerError>) => {
+          const msg = evt.data
+          if (msg.type === "line") {
+            if (open) Queue.offerUnsafe(queue, msg.line)
+            return
+          }
+          if (!close()) return
+          if (msg.type === "error") {
+            fail(queue, Object.assign(new Error(msg.error.message), msg.error))
+            return
+          }
+          if (msg.code === 0 || msg.code === 1) {
+            Queue.endUnsafe(queue)
+            return
+          }
+          fail(queue, error(msg.stderr, msg.code))
+        }
+
+        yield* Effect.acquireRelease(
+          Effect.sync(() => {
+            input.signal?.addEventListener("abort", onabort, { once: true })
+            w.postMessage({
+              kind: "files",
+              cwd: input.cwd,
+              args: filesArgs(input),
+            } satisfies Run)
           }),
+          () =>
+            Effect.sync(() => {
+              input.signal?.removeEventListener("abort", onabort)
+              w.onerror = null
+              w.onmessage = null
+            }),
         )
-      })
+      }),
+    )
+  }
 
-      return Service.of({
-        files: (input) => Stream.unwrap(files(input)),
-        search,
-      })
-    }),
-  )
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const source = (input: FilesInput) => {
+        const useWorker = !!input.signal && typeof Worker !== "undefined"
+        if (!useWorker && input.signal) {
+          log.warn("worker unavailable, ripgrep abort disabled")
+        }
+        return useWorker ? filesWorker(input) : filesDirect(input)
+      }
 
-  export const defaultLayer = layer.pipe(
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(CrossSpawnSpawner.defaultLayer),
-  )
+      const files: Interface["files"] = (input) => source(input)
 
-  export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
-    log.info("tree", input)
-    const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
-    interface Node {
-      name: string
-      children: Map<string, Node>
-    }
+      const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
+        log.info("tree", input)
+        const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
 
-    function dir(node: Node, name: string) {
-      const existing = node.children.get(name)
-      if (existing) return existing
-      const next = { name, children: new Map() }
-      node.children.set(name, next)
-      return next
-    }
+        interface Node {
+          name: string
+          children: Map<string, Node>
+        }
 
-    const root: Node = { name: "", children: new Map() }
-    for (const file of files) {
-      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 = dir(node, part)
-      }
-    }
+        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 {
-      let total = 0
-      for (const child of node.children.values()) {
-        total += 1 + count(child)
-      }
-      return total
-    }
+        function count(node: Node): number {
+          return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
+        }
 
-    const total = count(root)
-    const limit = input.limit ?? total
-    const lines: string[] = []
-    const queue: { node: Node; path: string }[] = []
-    for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
-      queue.push({ node: child, path: child.name })
-    }
+        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)
+          }
+        }
 
-    let used = 0
-    for (let i = 0; i < queue.length && used < limit; i++) {
-      const { node, path } = queue[i]
-      lines.push(path)
-      used++
-      for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
-        queue.push({ node: child, path: `${path}/${child.name}` })
-      }
-    }
+        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")
+      })
 
-    if (total > used) lines.push(`[${total - used} truncated]`)
+      const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
+        const useWorker = !!input.signal && typeof Worker !== "undefined"
+        if (!useWorker && input.signal) {
+          log.warn("worker unavailable, ripgrep abort disabled")
+        }
+        return yield* useWorker ? searchWorker(input) : searchDirect(input)
+      })
+
+      return Service.of({ files, tree, search })
+    }),
+  )
+
+  export const defaultLayer = layer
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
+
+  export function files(input: FilesInput) {
+    return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
+  }
+
+  export function tree(input: TreeInput) {
+    return runPromise((svc) => svc.tree(input))
+  }
 
-    return lines.join("\n")
+  export function search(input: SearchInput) {
+    return runPromise((svc) => svc.search(input))
   }
 }

+ 103 - 0
packages/opencode/src/file/ripgrep.worker.ts

@@ -0,0 +1,103 @@
+import { ripgrep } from "ripgrep"
+
+function env() {
+  const env = Object.fromEntries(
+    Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
+  )
+  delete env.RIPGREP_CONFIG_PATH
+  return env
+}
+
+function opts(cwd: string) {
+  return {
+    env: env(),
+    preopens: { ".": cwd },
+  }
+}
+
+type Run = {
+  kind: "files" | "search"
+  cwd: string
+  args: string[]
+}
+
+function text(input: unknown) {
+  if (typeof input === "string") return input
+  if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
+  if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
+  return String(input)
+}
+
+function error(input: unknown) {
+  if (input instanceof Error) {
+    return {
+      message: input.message,
+      name: input.name,
+      stack: input.stack,
+    }
+  }
+
+  return {
+    message: String(input),
+  }
+}
+
+function clean(file: string) {
+  return file.replace(/^\.[\\/]/, "")
+}
+
+onmessage = async (evt: MessageEvent<Run>) => {
+  const msg = evt.data
+
+  try {
+    if (msg.kind === "search") {
+      const ret = await ripgrep(msg.args, {
+        buffer: true,
+        ...opts(msg.cwd),
+      })
+      postMessage({
+        type: "result",
+        code: ret.code ?? 0,
+        stdout: ret.stdout ?? "",
+        stderr: ret.stderr ?? "",
+      })
+      return
+    }
+
+    let buf = ""
+    let err = ""
+    const out = {
+      write(chunk: unknown) {
+        buf += text(chunk)
+        const lines = buf.split(/\r?\n/)
+        buf = lines.pop() || ""
+        for (const line of lines) {
+          if (line) postMessage({ type: "line", line: clean(line) })
+        }
+      },
+    }
+    const stderr = {
+      write(chunk: unknown) {
+        err += text(chunk)
+      },
+    }
+
+    const ret = await ripgrep(msg.args, {
+      stdout: out,
+      stderr,
+      ...opts(msg.cwd),
+    })
+
+    if (buf) postMessage({ type: "line", line: clean(buf) })
+    postMessage({
+      type: "done",
+      code: ret.code ?? 0,
+      stderr: err,
+    })
+  } catch (err) {
+    postMessage({
+      type: "error",
+      error: error(err),
+    })
+  }
+}

+ 4 - 3
packages/opencode/src/session/prompt.ts

@@ -46,7 +46,7 @@ import { Process } from "@/util/process"
 import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
 import { EffectLogger } from "@/effect/logger"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
+import { attach, makeRuntime } from "@/effect/run-service"
 import { TaskTool, type TaskPromptOps } from "@/tool/task"
 import { SessionRunState } from "./run-state"
 
@@ -108,8 +108,9 @@ export namespace SessionPrompt {
 
       const run = {
         promise: <A, E>(effect: Effect.Effect<A, E>) =>
-          Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
-        fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
+          Effect.runPromise(attach(effect).pipe(Effect.provide(EffectLogger.layer))),
+        fork: <A, E>(effect: Effect.Effect<A, E>) =>
+          Effect.runFork(attach(effect).pipe(Effect.provide(EffectLogger.layer))),
       }
 
       const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {

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

@@ -1,6 +1,7 @@
 import path from "path"
 import { Effect } from "effect"
 import { EffectLogger } from "@/effect/logger"
+import { InstanceState } from "@/effect/instance-state"
 import type { Tool } from "./tool"
 import { Instance } from "../project/instance"
 import { AppFileSystem } from "../filesystem"
@@ -21,8 +22,9 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
 
   if (options?.bypass) return
 
+  const ins = yield* InstanceState.context
   const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
-  if (Instance.containsPath(full)) return
+  if (Instance.containsPath(full, ins)) return
 
   const kind = options?.kind ?? "file"
   const dir = kind === "directory" ? full : path.dirname(full)

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

@@ -1,13 +1,13 @@
-import z from "zod"
 import path from "path"
+import z from "zod"
 import { Effect, Option } from "effect"
 import * as Stream from "effect/Stream"
-import { Tool } from "./tool"
-import DESCRIPTION from "./glob.txt"
+import { InstanceState } from "@/effect/instance-state"
+import { AppFileSystem } from "../filesystem"
 import { Ripgrep } from "../file/ripgrep"
-import { Instance } from "../project/instance"
 import { assertExternalDirectoryEffect } from "./external-directory"
-import { AppFileSystem } from "../filesystem"
+import DESCRIPTION from "./glob.txt"
+import { Tool } from "./tool"
 
 export const GlobTool = Tool.define(
   "glob",
@@ -28,6 +28,7 @@ export const GlobTool = Tool.define(
       }),
       execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
         Effect.gen(function* () {
+          const ins = yield* InstanceState.context
           yield* ctx.ask({
             permission: "glob",
             patterns: [params.pattern],
@@ -38,8 +39,8 @@ export const GlobTool = Tool.define(
             },
           })
 
-          let search = params.path ?? Instance.directory
-          search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+          let search = params.path ?? ins.directory
+          search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
           const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
           if (info?.type === "File") {
             throw new Error(`glob path must be a directory: ${search}`)
@@ -48,14 +49,14 @@ export const GlobTool = Tool.define(
 
           const limit = 100
           let truncated = false
-          const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
+          const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
             Stream.mapEffect((file) =>
               Effect.gen(function* () {
                 const full = path.resolve(search, file)
                 const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
                 const mtime =
                   info?.mtime.pipe(
-                    Option.map((d) => d.getTime()),
+                    Option.map((date) => date.getTime()),
                     Option.getOrElse(() => 0),
                   ) ?? 0
                 return { path: full, mtime }
@@ -75,7 +76,7 @@ export const GlobTool = Tool.define(
           const output = []
           if (files.length === 0) output.push("No files found")
           if (files.length > 0) {
-            output.push(...files.map((f) => f.path))
+            output.push(...files.map((file) => file.path))
             if (truncated) {
               output.push("")
               output.push(
@@ -85,7 +86,7 @@ export const GlobTool = Tool.define(
           }
 
           return {
-            title: path.relative(Instance.worktree, search),
+            title: path.relative(ins.worktree, search),
             metadata: {
               count: files.length,
               truncated,

+ 36 - 39
packages/opencode/src/tool/grep.ts

@@ -1,13 +1,12 @@
+import path from "path"
 import z from "zod"
 import { Effect, Option } from "effect"
-import { Tool } from "./tool"
-import { Ripgrep } from "../file/ripgrep"
+import { InstanceState } from "@/effect/instance-state"
 import { AppFileSystem } from "../filesystem"
-
-import DESCRIPTION from "./grep.txt"
-import { Instance } from "../project/instance"
-import path from "path"
+import { Ripgrep } from "../file/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
+import DESCRIPTION from "./grep.txt"
+import { Tool } from "./tool"
 
 const MAX_LINE_LENGTH = 2000
 
@@ -46,15 +45,16 @@ export const GrepTool = Tool.define(
             },
           })
 
-          const searchPath = AppFileSystem.resolve(
-            path.isAbsolute(params.path ?? Instance.directory)
-              ? (params.path ?? Instance.directory)
-              : path.join(Instance.directory, params.path ?? "."),
+          const ins = yield* InstanceState.context
+          const search = AppFileSystem.resolve(
+            path.isAbsolute(params.path ?? ins.directory)
+              ? (params.path ?? ins.directory)
+              : path.join(ins.directory, params.path ?? "."),
           )
-          const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
-          const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
-          const file = info?.type === "Directory" ? undefined : [searchPath]
-          yield* assertExternalDirectoryEffect(ctx, searchPath, {
+          const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
+          const cwd = info?.type === "Directory" ? search : path.dirname(search)
+          const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
+          yield* assertExternalDirectoryEffect(ctx, search, {
             kind: info?.type === "Directory" ? "directory" : "file",
           })
 
@@ -63,8 +63,8 @@ export const GrepTool = Tool.define(
             pattern: params.pattern,
             glob: params.include ? [params.include] : undefined,
             file,
+            signal: ctx.abort,
           })
-
           if (result.items.length === 0) return empty
 
           const rows = result.items.map((item) => ({
@@ -101,46 +101,43 @@ export const GrepTool = Tool.define(
 
           const limit = 100
           const truncated = matches.length > limit
-          const finalMatches = truncated ? matches.slice(0, limit) : matches
-
-          if (finalMatches.length === 0) return empty
-
-          const totalMatches = matches.length
-          const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
-
-          let currentFile = ""
-          for (const match of finalMatches) {
-            if (currentFile !== match.path) {
-              if (currentFile !== "") {
-                outputLines.push("")
-              }
-              currentFile = match.path
-              outputLines.push(`${match.path}:`)
+          const final = truncated ? matches.slice(0, limit) : matches
+          if (final.length === 0) return empty
+
+          const total = matches.length
+          const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
+
+          let current = ""
+          for (const match of final) {
+            if (current !== match.path) {
+              if (current !== "") output.push("")
+              current = match.path
+              output.push(`${match.path}:`)
             }
-            const truncatedLineText =
+            const text =
               match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
-            outputLines.push(`  Line ${match.line}: ${truncatedLineText}`)
+            output.push(`  Line ${match.line}: ${text}`)
           }
 
           if (truncated) {
-            outputLines.push("")
-            outputLines.push(
-              `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
+            output.push("")
+            output.push(
+              `(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
             )
           }
 
           if (result.partial) {
-            outputLines.push("")
-            outputLines.push("(Some paths were inaccessible and skipped)")
+            output.push("")
+            output.push("(Some paths were inaccessible and skipped)")
           }
 
           return {
             title: params.pattern,
             metadata: {
-              matches: totalMatches,
+              matches: total,
               truncated,
             },
-            output: outputLines.join("\n"),
+            output: output.join("\n"),
           }
         }).pipe(Effect.orDie),
     }

+ 31 - 43
packages/opencode/src/tool/ls.ts

@@ -1,12 +1,12 @@
+import * as path from "path"
 import z from "zod"
 import { Effect } from "effect"
 import * as Stream from "effect/Stream"
-import { Tool } from "./tool"
-import * as path from "path"
-import DESCRIPTION from "./ls.txt"
-import { Instance } from "../project/instance"
+import { InstanceState } from "@/effect/instance-state"
 import { Ripgrep } from "../file/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
+import DESCRIPTION from "./ls.txt"
+import { Tool } from "./tool"
 
 export const IGNORE_PATTERNS = [
   "node_modules/",
@@ -53,80 +53,68 @@ export const ListTool = Tool.define(
       }),
       execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
         Effect.gen(function* () {
-          const searchPath = path.resolve(Instance.directory, params.path || ".")
-          yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
+          const ins = yield* InstanceState.context
+          const search = path.resolve(ins.directory, params.path || ".")
+          yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
 
           yield* ctx.ask({
             permission: "list",
-            patterns: [searchPath],
+            patterns: [search],
             always: ["*"],
             metadata: {
-              path: searchPath,
+              path: search,
             },
           })
 
-          const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
-          const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
-            Stream.take(LIMIT),
+          const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || [])
+          const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe(
+            Stream.take(LIMIT + 1),
             Stream.runCollect,
             Effect.map((chunk) => [...chunk]),
           )
 
-          // Build directory structure
-          const dirs = new Set<string>()
-          const filesByDir = new Map<string, string[]>()
+          const truncated = files.length > LIMIT
+          if (truncated) files.length = LIMIT
 
+          const dirs = new Set<string>()
+          const map = new Map<string, string[]>()
           for (const file of files) {
             const dir = path.dirname(file)
             const parts = dir === "." ? [] : dir.split("/")
-
-            // Add all parent directories
             for (let i = 0; i <= parts.length; i++) {
-              const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
-              dirs.add(dirPath)
+              dirs.add(i === 0 ? "." : parts.slice(0, i).join("/"))
             }
-
-            // Add file to its directory
-            if (!filesByDir.has(dir)) filesByDir.set(dir, [])
-            filesByDir.get(dir)!.push(path.basename(file))
+            if (!map.has(dir)) map.set(dir, [])
+            map.get(dir)!.push(path.basename(file))
           }
 
-          function renderDir(dirPath: string, depth: number): string {
+          function render(dir: string, depth: number): string {
             const indent = "  ".repeat(depth)
             let output = ""
+            if (depth > 0) output += `${indent}${path.basename(dir)}/\n`
 
-            if (depth > 0) {
-              output += `${indent}${path.basename(dirPath)}/\n`
-            }
-
-            const childIndent = "  ".repeat(depth + 1)
-            const children = Array.from(dirs)
-              .filter((d) => path.dirname(d) === dirPath && d !== dirPath)
+            const child = "  ".repeat(depth + 1)
+            const dirs2 = Array.from(dirs)
+              .filter((item) => path.dirname(item) === dir && item !== dir)
               .sort()
-
-            // Render subdirectories first
-            for (const child of children) {
-              output += renderDir(child, depth + 1)
+            for (const item of dirs2) {
+              output += render(item, depth + 1)
             }
 
-            // Render files
-            const files = filesByDir.get(dirPath) || []
+            const files = map.get(dir) || []
             for (const file of files.sort()) {
-              output += `${childIndent}${file}\n`
+              output += `${child}${file}\n`
             }
-
             return output
           }
 
-          const output = `${searchPath}/\n` + renderDir(".", 0)
-
           return {
-            title: path.relative(Instance.worktree, searchPath),
+            title: path.relative(ins.worktree, search),
             metadata: {
               count: files.length,
-              truncated: files.length >= LIMIT,
+              truncated,
             },
-            output,
+            output: `${search}/\n` + render(".", 0),
           }
         }).pipe(Effect.orDie),
     }

+ 6 - 7
packages/opencode/src/tool/skill.ts

@@ -2,11 +2,11 @@ import path from "path"
 import { pathToFileURL } from "url"
 import z from "zod"
 import { Effect } from "effect"
-import { EffectLogger } from "@/effect/logger"
 import * as Stream from "effect/Stream"
-import { Tool } from "./tool"
-import { Skill } from "../skill"
+import { EffectLogger } from "@/effect/logger"
 import { Ripgrep } from "../file/ripgrep"
+import { Skill } from "../skill"
+import { Tool } from "./tool"
 
 const Parameters = z.object({
   name: z.string().describe("The name of the skill from available_skills"),
@@ -17,6 +17,7 @@ export const SkillTool = Tool.define(
   Effect.gen(function* () {
     const skill = yield* Skill.Service
     const rg = yield* Ripgrep.Service
+
     return () =>
       Effect.gen(function* () {
         const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
@@ -45,10 +46,9 @@ export const SkillTool = Tool.define(
           execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
             Effect.gen(function* () {
               const info = yield* skill.get(params.name)
-
               if (!info) {
                 const all = yield* skill.all()
-                const available = all.map((s) => s.name).join(", ")
+                const available = all.map((item) => item.name).join(", ")
                 throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
               }
 
@@ -61,9 +61,8 @@ export const SkillTool = Tool.define(
 
               const dir = path.dirname(info.location)
               const base = pathToFileURL(dir).href
-
               const limit = 10
-              const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
+              const files = yield* rg.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),

+ 130 - 17
packages/opencode/test/file/ripgrep.test.ts

@@ -6,6 +6,21 @@ import path from "path"
 import { tmpdir } from "../fixture/fixture"
 import { Ripgrep } from "../../src/file/ripgrep"
 
+async function seed(dir: string, count: number, size = 16) {
+  const txt = "a".repeat(size)
+  await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`)))
+}
+
+function env(name: string, value: string | undefined) {
+  const prev = process.env[name]
+  if (value === undefined) delete process.env[name]
+  else process.env[name] = value
+  return () => {
+    if (prev === undefined) delete process.env[name]
+    else process.env[name] = prev
+  }
+}
+
 describe("file.ripgrep", () => {
   test("defaults to include hidden", async () => {
     await using tmp = await tmpdir({
@@ -16,11 +31,9 @@ describe("file.ripgrep", () => {
       },
     })
 
-    const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
-    const hasVisible = files.includes("visible.txt")
-    const hasHidden = files.includes(path.join(".opencode", "thing.json"))
-    expect(hasVisible).toBe(true)
-    expect(hasHidden).toBe(true)
+    const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path }))
+    expect(files.includes("visible.txt")).toBe(true)
+    expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
   })
 
   test("hidden false excludes hidden", async () => {
@@ -32,15 +45,11 @@ describe("file.ripgrep", () => {
       },
     })
 
-    const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
-    const hasVisible = files.includes("visible.txt")
-    const hasHidden = files.includes(path.join(".opencode", "thing.json"))
-    expect(hasVisible).toBe(true)
-    expect(hasHidden).toBe(false)
+    const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false }))
+    expect(files.includes("visible.txt")).toBe(true)
+    expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
   })
-})
 
-describe("Ripgrep.Service", () => {
   test("search returns empty when nothing matches", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {
@@ -48,15 +57,119 @@ describe("Ripgrep.Service", () => {
       },
     })
 
-    const result = await Effect.gen(function* () {
-      const rg = yield* Ripgrep.Service
-      return yield* rg.search({ cwd: tmp.path, pattern: "needle" })
-    }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
-
+    const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
     expect(result.partial).toBe(false)
     expect(result.items).toEqual([])
   })
 
+  test("search returns match metadata with normalized path", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await fs.mkdir(path.join(dir, "src"), { recursive: true })
+        await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
+      },
+    })
+
+    const result = await Ripgrep.search({ cwd: tmp.path, 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")
+  })
+
+  test("files returns empty when glob matches no files in worker mode", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
+        await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
+      },
+    })
+
+    const ctl = new AbortController()
+    const files = await Array.fromAsync(
+      await Ripgrep.files({
+        cwd: tmp.path,
+        glob: ["packages/*"],
+        signal: ctl.signal,
+      }),
+    )
+
+    expect(files).toEqual([])
+  })
+
+  test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
+      },
+    })
+
+    const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
+    try {
+      const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
+      expect(result.items).toHaveLength(1)
+    } finally {
+      restore()
+    }
+  })
+
+  test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
+      },
+    })
+
+    const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
+    try {
+      const ctl = new AbortController()
+      const result = await Ripgrep.search({
+        cwd: tmp.path,
+        pattern: "needle",
+        signal: ctl.signal,
+      })
+      expect(result.items).toHaveLength(1)
+    } finally {
+      restore()
+    }
+  })
+
+  test("aborts files scan in worker mode", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await seed(dir, 4000)
+      },
+    })
+
+    const ctl = new AbortController()
+    const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal })
+    const pending = Array.fromAsync(iter)
+    setTimeout(() => ctl.abort(), 0)
+
+    const err = await pending.catch((err) => err)
+    expect(err).toBeInstanceOf(Error)
+    expect(err.name).toBe("AbortError")
+  }, 15_000)
+
+  test("aborts search in worker mode", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await seed(dir, 512, 64 * 1024)
+      },
+    })
+
+    const ctl = new AbortController()
+    const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal })
+    setTimeout(() => ctl.abort(), 0)
+
+    const err = await pending.catch((err) => err)
+    expect(err).toBeInstanceOf(Error)
+    expect(err.name).toBe("AbortError")
+  }, 15_000)
+})
+
+describe("Ripgrep.Service", () => {
   test("search returns matched rows", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {

+ 3 - 3
packages/opencode/test/tool/grep.test.ts

@@ -32,18 +32,18 @@ const ctx = {
   ask: () => Effect.void,
 }
 
-const projectRoot = path.join(__dirname, "../..")
+const root = path.join(__dirname, "../..")
 
 describe("tool.grep", () => {
   it.live("basic search", () =>
     Effect.gen(function* () {
       const info = yield* GrepTool
       const grep = yield* info.init()
-      const result = yield* provideInstance(projectRoot)(
+      const result = yield* provideInstance(root)(
         grep.execute(
           {
             pattern: "export",
-            path: path.join(projectRoot, "src/tool"),
+            path: path.join(root, "src/tool"),
             include: "*.ts",
           },
           ctx,

+ 40 - 43
packages/opencode/test/tool/skill.test.ts

@@ -1,10 +1,6 @@
-import { Effect, Layer, ManagedRuntime } from "effect"
-import { Agent } from "../../src/agent/agent"
-import { Skill } from "../../src/skill"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
-import { Truncate } from "../../src/tool/truncate"
-import { afterEach, describe, expect, test } from "bun:test"
+import { Effect, Layer } from "effect"
+import { afterEach, describe, expect } from "bun:test"
 import path from "path"
 import { pathToFileURL } from "url"
 import type { Permission } from "../../src/permission"
@@ -12,7 +8,7 @@ import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { SkillTool } from "../../src/tool/skill"
 import { ToolRegistry } from "../../src/tool/registry"
-import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
+import { provideTmpdirInstance } from "../fixture/fixture"
 import { SessionID, MessageID } from "../../src/session/schema"
 import { testEffect } from "../lib/effect"
 
@@ -131,14 +127,15 @@ description: ${description}
     ),
   )
 
-  test("execute returns skill content block with files", async () => {
-    await using tmp = await tmpdir({
-      git: true,
-      init: async (dir) => {
-        const skillDir = path.join(dir, ".opencode", "skill", "tool-skill")
-        await Bun.write(
-          path.join(skillDir, "SKILL.md"),
-          `---
+  it.live("execute returns skill content block with files", () =>
+    provideTmpdirInstance(
+      (dir) =>
+        Effect.gen(function* () {
+          const skill = path.join(dir, ".opencode", "skill", "tool-skill")
+          yield* Effect.promise(() =>
+            Bun.write(
+              path.join(skill, "SKILL.md"),
+              `---
 name: tool-skill
 description: Skill for tool tests.
 ---
@@ -147,23 +144,27 @@ description: Skill for tool tests.
 
 Use this skill.
 `,
-        )
-        await Bun.write(path.join(skillDir, "scripts", "demo.txt"), "demo")
-      },
-    })
-
-    const home = process.env.OPENCODE_TEST_HOME
-    process.env.OPENCODE_TEST_HOME = tmp.path
-
-    try {
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const runtime = ManagedRuntime.make(
-            Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
+            ),
+          )
+          yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
+
+          const home = process.env.OPENCODE_TEST_HOME
+          process.env.OPENCODE_TEST_HOME = dir
+          yield* Effect.addFinalizer(() =>
+            Effect.sync(() => {
+              process.env.OPENCODE_TEST_HOME = home
+            }),
           )
-          const info = await runtime.runPromise(SkillTool)
-          const tool = await runtime.runPromise(info.init())
+
+          const registry = yield* ToolRegistry.Service
+          const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
+          const tool = (yield* registry.tools({
+            providerID: "opencode" as any,
+            modelID: "gpt-5" as any,
+            agent,
+          })).find((tool) => tool.id === SkillTool.id)
+          if (!tool) throw new Error("Skill tool not found")
+
           const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
           const ctx: Tool.Context = {
             ...baseCtx,
@@ -173,23 +174,19 @@ Use this skill.
               }),
           }
 
-          const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
-          const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
-          const file = path.resolve(dir, "scripts", "demo.txt")
+          const result = yield* tool.execute({ name: "tool-skill" }, ctx)
+          const file = path.resolve(skill, "scripts", "demo.txt")
 
           expect(requests.length).toBe(1)
           expect(requests[0].permission).toBe("skill")
           expect(requests[0].patterns).toContain("tool-skill")
           expect(requests[0].always).toContain("tool-skill")
-
-          expect(result.metadata.dir).toBe(dir)
+          expect(result.metadata.dir).toBe(skill)
           expect(result.output).toContain(`<skill_content name="tool-skill">`)
-          expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(dir).href}`)
+          expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(skill).href}`)
           expect(result.output).toContain(`<file>${file}</file>`)
-        },
-      })
-    } finally {
-      process.env.OPENCODE_TEST_HOME = home
-    }
-  })
+        }),
+      { git: true },
+    ),
+  )
 })