Просмотр исходного кода

feat: unwrap file namespaces to flat exports + barrel

Kit Langton 1 день назад
Родитель
Сommit
e61fe8e5f8
35 измененных файлов с 834 добавлено и 839 удалено
  1. 1 1
      packages/opencode/src/cli/cmd/debug/file.ts
  2. 1 1
      packages/opencode/src/cli/cmd/debug/ripgrep.ts
  3. 3 3
      packages/opencode/src/effect/app-runtime.ts
  4. 1 1
      packages/opencode/src/effect/bootstrap-runtime.ts
  5. 2 2
      packages/opencode/src/file/file.ts
  6. 66 68
      packages/opencode/src/file/ignore.ts
  7. 5 0
      packages/opencode/src/file/index.ts
  8. 16 18
      packages/opencode/src/file/protected.ts
  9. 484 486
      packages/opencode/src/file/ripgrep.ts
  10. 102 104
      packages/opencode/src/file/time.ts
  11. 121 123
      packages/opencode/src/file/watcher.ts
  12. 1 1
      packages/opencode/src/project/bootstrap.ts
  13. 1 1
      packages/opencode/src/project/vcs.ts
  14. 1 1
      packages/opencode/src/server/instance/file.ts
  15. 1 1
      packages/opencode/src/session/prompt.ts
  16. 1 1
      packages/opencode/src/tool/apply_patch.ts
  17. 2 2
      packages/opencode/src/tool/edit.ts
  18. 1 1
      packages/opencode/src/tool/glob.ts
  19. 1 1
      packages/opencode/src/tool/grep.ts
  20. 1 1
      packages/opencode/src/tool/read.ts
  21. 2 2
      packages/opencode/src/tool/registry.ts
  22. 1 1
      packages/opencode/src/tool/skill.ts
  23. 2 2
      packages/opencode/src/tool/write.ts
  24. 1 1
      packages/opencode/test/file/ignore.test.ts
  25. 1 1
      packages/opencode/test/file/ripgrep.test.ts
  26. 2 2
      packages/opencode/test/file/time.test.ts
  27. 1 1
      packages/opencode/test/file/watcher.test.ts
  28. 1 1
      packages/opencode/test/project/vcs.test.ts
  29. 2 2
      packages/opencode/test/session/prompt-effect.test.ts
  30. 2 2
      packages/opencode/test/session/snapshot-tool-race.test.ts
  31. 3 3
      packages/opencode/test/tool/edit.test.ts
  32. 1 1
      packages/opencode/test/tool/glob.test.ts
  33. 1 1
      packages/opencode/test/tool/grep.test.ts
  34. 1 1
      packages/opencode/test/tool/read.test.ts
  35. 1 1
      packages/opencode/test/tool/write.test.ts

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

@@ -1,7 +1,7 @@
 import { EOL } from "os"
 import { AppRuntime } from "@/effect/app-runtime"
 import { File } from "../../../file"
-import { Ripgrep } from "@/file/ripgrep"
+import { Ripgrep } from "@/file"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
 

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

@@ -1,7 +1,7 @@
 import { EOL } from "os"
 import { Effect, Stream } from "effect"
 import { AppRuntime } from "../../../effect/app-runtime"
-import { Ripgrep } from "../../../file/ripgrep"
+import { Ripgrep } from "../../../file"
 import { Instance } from "../../../project/instance"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"

+ 3 - 3
packages/opencode/src/effect/app-runtime.ts

@@ -8,10 +8,10 @@ import { Auth } from "@/auth"
 import { Account } from "@/account"
 import { Config } from "@/config"
 import { Git } from "@/git"
-import { Ripgrep } from "@/file/ripgrep"
-import { FileTime } from "@/file/time"
+import { Ripgrep } from "@/file"
+import { FileTime } from "@/file"
 import { File } from "@/file"
-import { FileWatcher } from "@/file/watcher"
+import { FileWatcher } from "@/file"
 import { Storage } from "@/storage"
 import { Snapshot } from "@/snapshot"
 import { Plugin } from "@/plugin"

+ 1 - 1
packages/opencode/src/effect/bootstrap-runtime.ts

@@ -3,7 +3,7 @@ import { memoMap } from "./run-service"
 
 import { Plugin } from "@/plugin"
 import { LSP } from "@/lsp"
-import { FileWatcher } from "@/file/watcher"
+import { FileWatcher } from "@/file"
 import { Format } from "@/format"
 import { ShareNext } from "@/share"
 import { File } from "@/file"

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

@@ -13,8 +13,8 @@ import z from "zod"
 import { Global } from "../global"
 import { Instance } from "../project/instance"
 import { Log } from "../util"
-import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
+import * as Protected from "./protected"
+import * as Ripgrep from "./ripgrep"
 
 export const Info = z
   .object({

+ 66 - 68
packages/opencode/src/file/ignore.ts

@@ -1,81 +1,79 @@
 import { Glob } from "@opencode-ai/shared/util/glob"
 
-export namespace FileIgnore {
-  const FOLDERS = new Set([
-    "node_modules",
-    "bower_components",
-    ".pnpm-store",
-    "vendor",
-    ".npm",
-    "dist",
-    "build",
-    "out",
-    ".next",
-    "target",
-    "bin",
-    "obj",
-    ".git",
-    ".svn",
-    ".hg",
-    ".vscode",
-    ".idea",
-    ".turbo",
-    ".output",
-    "desktop",
-    ".sst",
-    ".cache",
-    ".webkit-cache",
-    "__pycache__",
-    ".pytest_cache",
-    "mypy_cache",
-    ".history",
-    ".gradle",
-  ])
+const FOLDERS = new Set([
+  "node_modules",
+  "bower_components",
+  ".pnpm-store",
+  "vendor",
+  ".npm",
+  "dist",
+  "build",
+  "out",
+  ".next",
+  "target",
+  "bin",
+  "obj",
+  ".git",
+  ".svn",
+  ".hg",
+  ".vscode",
+  ".idea",
+  ".turbo",
+  ".output",
+  "desktop",
+  ".sst",
+  ".cache",
+  ".webkit-cache",
+  "__pycache__",
+  ".pytest_cache",
+  "mypy_cache",
+  ".history",
+  ".gradle",
+])
 
-  const FILES = [
-    "**/*.swp",
-    "**/*.swo",
+const FILES = [
+  "**/*.swp",
+  "**/*.swo",
 
-    "**/*.pyc",
+  "**/*.pyc",
 
-    // OS
-    "**/.DS_Store",
-    "**/Thumbs.db",
+  // OS
+  "**/.DS_Store",
+  "**/Thumbs.db",
 
-    // Logs & temp
-    "**/logs/**",
-    "**/tmp/**",
-    "**/temp/**",
-    "**/*.log",
+  // Logs & temp
+  "**/logs/**",
+  "**/tmp/**",
+  "**/temp/**",
+  "**/*.log",
 
-    // Coverage/test outputs
-    "**/coverage/**",
-    "**/.nyc_output/**",
-  ]
+  // Coverage/test outputs
+  "**/coverage/**",
+  "**/.nyc_output/**",
+]
 
-  export const PATTERNS = [...FILES, ...FOLDERS]
+export const PATTERNS = [...FILES, ...FOLDERS]
 
-  export function match(
-    filepath: string,
-    opts?: {
-      extra?: string[]
-      whitelist?: string[]
-    },
-  ) {
-    for (const pattern of opts?.whitelist || []) {
-      if (Glob.match(pattern, filepath)) return false
-    }
-
-    const parts = filepath.split(/[/\\]/)
-    for (let i = 0; i < parts.length; i++) {
-      if (FOLDERS.has(parts[i])) return true
-    }
+export function match(
+  filepath: string,
+  opts?: {
+    extra?: string[]
+    whitelist?: string[]
+  },
+) {
+  for (const pattern of opts?.whitelist || []) {
+    if (Glob.match(pattern, filepath)) return false
+  }
 
-    const extra = opts?.extra || []
-    for (const pattern of [...FILES, ...extra]) {
-      if (Glob.match(pattern, filepath)) return true
-    }
+  const parts = filepath.split(/[/\\]/)
+  for (let i = 0; i < parts.length; i++) {
+    if (FOLDERS.has(parts[i])) return true
+  }
 
-    return false
+  const extra = opts?.extra || []
+  for (const pattern of [...FILES, ...extra]) {
+    if (Glob.match(pattern, filepath)) return true
   }
+
+  return false
 }

+ 5 - 0
packages/opencode/src/file/index.ts

@@ -1 +1,6 @@
 export * as File from "./file"
+export * as Protected from "./protected"
+export * as FileIgnore from "./ignore"
+export * as FileWatcher from "./watcher"
+export * as FileTime from "./time"
+export * as Ripgrep from "./ripgrep"

+ 16 - 18
packages/opencode/src/file/protected.ts

@@ -37,23 +37,21 @@ const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes"
 
 const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
 
-export namespace Protected {
-  /** Directory basenames to skip when scanning the home directory. */
-  export function names(): ReadonlySet<string> {
-    if (process.platform === "darwin") return new Set(DARWIN_HOME)
-    if (process.platform === "win32") return new Set(WIN32_HOME)
-    return new Set()
-  }
+/** Directory basenames to skip when scanning the home directory. */
+export function names(): ReadonlySet<string> {
+  if (process.platform === "darwin") return new Set(DARWIN_HOME)
+  if (process.platform === "win32") return new Set(WIN32_HOME)
+  return new Set()
+}
 
-  /** Absolute paths that should never be watched, stated, or scanned. */
-  export function paths(): string[] {
-    if (process.platform === "darwin")
-      return [
-        ...DARWIN_HOME.map((n) => path.join(home, n)),
-        ...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
-        ...DARWIN_ROOT,
-      ]
-    if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
-    return []
-  }
+/** Absolute paths that should never be watched, stated, or scanned. */
+export function paths(): string[] {
+  if (process.platform === "darwin")
+    return [
+      ...DARWIN_HOME.map((n) => path.join(home, n)),
+      ...DARWIN_LIBRARY.map((n) => path.join(home, "Library", n)),
+      ...DARWIN_ROOT,
+    ]
+  if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
+  return []
 }

+ 484 - 486
packages/opencode/src/file/ripgrep.ts

@@ -8,568 +8,566 @@ import { ripgrep } from "ripgrep"
 import { Filesystem } from "@/util"
 import { Log } from "@/util"
 
-export namespace Ripgrep {
-  const log = Log.create({ service: "ripgrep" })
-
-  const Stats = z.object({
-    elapsed: z.object({
-      secs: z.number(),
-      nanos: z.number(),
-      human: z.string(),
+const log = Log.create({ service: "ripgrep" })
+
+const Stats = z.object({
+  elapsed: z.object({
+    secs: z.number(),
+    nanos: z.number(),
+    human: z.string(),
+  }),
+  searches: z.number(),
+  searches_with_match: z.number(),
+  bytes_searched: z.number(),
+  bytes_printed: z.number(),
+  matched_lines: z.number(),
+  matches: z.number(),
+})
+
+const Begin = z.object({
+  type: z.literal("begin"),
+  data: z.object({
+    path: z.object({
+      text: z.string(),
     }),
-    searches: z.number(),
-    searches_with_match: z.number(),
-    bytes_searched: z.number(),
-    bytes_printed: z.number(),
-    matched_lines: z.number(),
-    matches: z.number(),
-  })
-
-  const Begin = z.object({
-    type: z.literal("begin"),
-    data: z.object({
-      path: z.object({
-        text: z.string(),
-      }),
+  }),
+})
+
+export const Match = z.object({
+  type: z.literal("match"),
+  data: z.object({
+    path: z.object({
+      text: z.string(),
     }),
-  })
-
-  export const Match = z.object({
-    type: z.literal("match"),
-    data: z.object({
-      path: z.object({
-        text: z.string(),
-      }),
-      lines: z.object({
-        text: z.string(),
-      }),
-      line_number: z.number(),
-      absolute_offset: z.number(),
-      submatches: z.array(
-        z.object({
-          match: z.object({
-            text: z.string(),
-          }),
-          start: z.number(),
-          end: z.number(),
-        }),
-      ),
+    lines: z.object({
+      text: z.string(),
     }),
-  })
-
-  const End = z.object({
-    type: z.literal("end"),
-    data: z.object({
-      path: z.object({
-        text: z.string(),
+    line_number: z.number(),
+    absolute_offset: z.number(),
+    submatches: z.array(
+      z.object({
+        match: z.object({
+          text: z.string(),
+        }),
+        start: z.number(),
+        end: z.number(),
       }),
-      binary_offset: z.number().nullable(),
-      stats: Stats,
+    ),
+  }),
+})
+
+const End = z.object({
+  type: z.literal("end"),
+  data: z.object({
+    path: z.object({
+      text: z.string(),
     }),
-  })
-
-  const Summary = z.object({
-    type: z.literal("summary"),
-    data: z.object({
-      elapsed_total: z.object({
-        human: z.string(),
-        nanos: z.number(),
-        secs: z.number(),
-      }),
-      stats: Stats,
+    binary_offset: z.number().nullable(),
+    stats: Stats,
+  }),
+})
+
+const Summary = z.object({
+  type: z.literal("summary"),
+  data: z.object({
+    elapsed_total: z.object({
+      human: z.string(),
+      nanos: z.number(),
+      secs: z.number(),
     }),
-  })
+    stats: Stats,
+  }),
+})
+
+const Result = z.union([Begin, Match, End, Summary])
+
+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>
+export type Row = Match["data"]
+
+export interface SearchResult {
+  items: Item[]
+  partial: boolean
+}
 
-  const Result = z.union([Begin, Match, End, Summary])
+export interface FilesInput {
+  cwd: string
+  glob?: string[]
+  hidden?: boolean
+  follow?: boolean
+  maxDepth?: number
+  signal?: AbortSignal
+}
 
-  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>
-  export type Row = Match["data"]
+export interface SearchInput {
+  cwd: string
+  pattern: string
+  glob?: string[]
+  limit?: number
+  follow?: boolean
+  file?: string[]
+  signal?: AbortSignal
+}
 
-  export interface SearchResult {
-    items: Item[]
-    partial: boolean
-  }
+export interface TreeInput {
+  cwd: string
+  limit?: number
+  signal?: AbortSignal
+}
 
-  export interface FilesInput {
-    cwd: string
-    glob?: string[]
-    hidden?: boolean
-    follow?: boolean
-    maxDepth?: number
-    signal?: AbortSignal
-  }
+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 interface SearchInput {
-    cwd: string
-    pattern: string
-    glob?: string[]
-    limit?: number
-    follow?: boolean
-    file?: string[]
-    signal?: AbortSignal
-  }
+export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
 
-  export interface TreeInput {
-    cwd: string
-    limit?: number
-    signal?: AbortSignal
-  }
+type Run = { kind: "files" | "search"; cwd: string; args: string[] }
 
-  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>
-  }
+type WorkerResult = {
+  type: "result"
+  code: number
+  stdout: string
+  stderr: string
+}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
+type WorkerLine = {
+  type: "line"
+  line: string
+}
 
-  type Run = { kind: "files" | "search"; cwd: string; args: string[] }
+type WorkerDone = {
+  type: "done"
+  code: number
+  stderr: string
+}
 
-  type WorkerResult = {
-    type: "result"
-    code: number
-    stdout: string
-    stderr: string
+type WorkerError = {
+  type: "error"
+  error: {
+    message: string
+    name?: string
+    stack?: string
   }
+}
 
-  type WorkerLine = {
-    type: "line"
-    line: 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
+}
 
-  type WorkerDone = {
-    type: "done"
-    code: number
-    stderr: 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)
+}
 
-  type WorkerError = {
-    type: "error"
-    error: {
-      message: string
-      name?: string
-      stack?: string
-    }
-  }
+function toError(input: unknown) {
+  if (input instanceof Error) return input
+  if (typeof input === "string") return new Error(input)
+  return new Error(String(input))
+}
 
-  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 abort(signal?: AbortSignal) {
+  const err = signal?.reason
+  if (err instanceof Error) return err
+  const out = new Error("Aborted")
+  out.name = "AbortError"
+  return out
+}
 
-  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(stderr: string, code: number) {
+  const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
+  err.name = "RipgrepError"
+  return err
+}
 
-  function toError(input: unknown) {
-    if (input instanceof Error) return input
-    if (typeof input === "string") return new Error(input)
-    return new Error(String(input))
-  }
+function clean(file: string) {
+  return path.normalize(file.replace(/^\.[\\/]/, ""))
+}
 
-  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 row(data: Row): Row {
+  return {
+    ...data,
+    path: {
+      ...data.path,
+      text: clean(data.path.text),
+    },
   }
+}
 
-  function error(stderr: string, code: number) {
-    const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
-    err.name = "RipgrepError"
-    return err
+function opts(cwd: string) {
+  return {
+    env: env(),
+    preopens: { ".": cwd },
   }
+}
 
-  function clean(file: string) {
-    return path.normalize(file.replace(/^\.[\\/]/, ""))
-  }
+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,
+            }),
+          ),
+    ),
+  )
+}
 
-  function row(data: Row): Row {
-    return {
-      ...data,
-      path: {
-        ...data.path,
-        text: clean(data.path.text),
-      },
+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 glob of input.glob) {
+      args.push(`--glob=${glob}`)
     }
   }
+  args.push(".")
+  return args
+}
 
-  function opts(cwd: string) {
-    return {
-      env: env(),
-      preopens: { ".": 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
+}
 
-  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,
-              }),
-            ),
-      ),
-    )
-  }
-
-  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 glob of input.glob) {
-        args.push(`--glob=${glob}`)
-      }
-    }
-    args.push(".")
-    return args
-  }
+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)] : []))
+}
 
-  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
-  }
+declare const OPENCODE_RIPGREP_WORKER_PATH: string
 
-  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)] : []))
+function target(): Effect.Effect<string | URL, Error> {
+  if (typeof OPENCODE_RIPGREP_WORKER_PATH !== "undefined") {
+    return Effect.succeed(OPENCODE_RIPGREP_WORKER_PATH)
   }
+  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))))
+}
 
-  declare const OPENCODE_RIPGREP_WORKER_PATH: string
+function worker() {
+  return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
+}
 
-  function target(): Effect.Effect<string | URL, Error> {
-    if (typeof OPENCODE_RIPGREP_WORKER_PATH !== "undefined") {
-      return Effect.succeed(OPENCODE_RIPGREP_WORKER_PATH)
-    }
-    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))))
+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
+}
 
-  function worker() {
-    return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
-  }
+function fail(queue: Queue.Queue<string, Error | Cause.Done>, err: Error) {
+  Queue.failCauseUnsafe(queue, Cause.fail(err))
+}
 
-  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
-  }
+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,
+      }))
+    }),
+  )
+}
 
-  function fail(queue: Queue.Queue<string, Error | Cause.Done>, err: Error) {
-    Queue.failCauseUnsafe(queue, Cause.fail(err))
-  }
+function searchWorker(input: SearchInput) {
+  if (input.signal?.aborted) return Effect.fail(abort(input.signal))
 
-  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.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)
         }
-        return Effect.sync(() => ({
-          items: ret.code === 1 ? [] : parse(out),
-          partial: ret.code === 2,
-        }))
-      }),
-    )
-  }
+        const onabort = () => done(Effect.fail(abort(input.signal)))
 
-  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)
+        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
           }
-          const onabort = () => done(Effect.fail(abort(input.signal)))
-
-          w.onerror = (evt) => {
-            done(Effect.fail(toError(evt.error ?? evt.message)))
+          if (msg.code === 1) {
+            done(Effect.succeed({ items: [], partial: false }))
+            return
           }
-          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 (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,
+            })),
+          )
+        }
 
-          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)
+        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()),
+  )
+}
 
-          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 = ""
 
-  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 out = {
+        write(chunk: unknown) {
+          buf = drain(buf, chunk, (line) => {
+            Queue.offerUnsafe(queue, clean(line))
+          })
+        },
+      }
 
-        const stderr = {
-          write(chunk: unknown) {
-            err += text(chunk)
-          },
-        }
+      const stderr = {
+        write(chunk: unknown) {
+          err += text(chunk)
+        },
+      }
 
-        yield* Effect.forkScoped(
-          Effect.gen(function* () {
-            yield* check(input.cwd)
-            const ret = yield* Effect.tryPromise({
-              try: () =>
-                ripgrep(filesArgs(input), {
-                  stdout: out,
-                  stderr,
-                  ...opts(input.cwd),
-                }),
-              catch: toError,
-            })
-            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)
+      yield* Effect.forkScoped(
+        Effect.gen(function* () {
+          yield* check(input.cwd)
+          const ret = yield* Effect.tryPromise({
+            try: () =>
+              ripgrep(filesArgs(input), {
+                stdout: out,
+                stderr,
+                ...opts(input.cwd),
               }),
-            ),
+            catch: toError,
+          })
+          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)
+            }),
           ),
-        )
-      }),
-    )
-  }
+        ),
+      )
+    }),
+  )
+}
 
-  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
-        }
+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
+      }
 
-        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))
-        }
+      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))
+      }
 
-        w.onerror = (evt) => {
-          if (!close()) return
-          fail(queue, toError(evt.error ?? evt.message))
+      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
         }
-        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))
+        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(
+      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?.addEventListener("abort", onabort, { once: true })
-            w.postMessage({
-              kind: "files",
-              cwd: input.cwd,
-              args: filesArgs(input),
-            } satisfies Run)
+            input.signal?.removeEventListener("abort", onabort)
+            w.onerror = null
+            w.onmessage = null
           }),
-          () =>
-            Effect.sync(() => {
-              input.signal?.removeEventListener("abort", onabort)
-              w.onerror = null
-              w.onmessage = null
-            }),
-        )
-      }),
-    )
-  }
+      )
+    }),
+  )
+}
 
-  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 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)
+    }
 
-      const files: Interface["files"] = (input) => source(input)
+    const files: Interface["files"] = (input) => source(input)
 
-      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))
+    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))
 
-        interface Node {
-          name: string
-          children: Map<string, Node>
-        }
+      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 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)
-        }
+      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 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}` })),
-          )
-        }
+      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]`)
+      return lines.join("\n")
+    })
 
-      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)
-      })
+    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 })
-    }),
-  )
+    return Service.of({ files, tree, search })
+  }),
+)
 
-  export const defaultLayer = layer
-}
+export const defaultLayer = layer

+ 102 - 104
packages/opencode/src/file/time.ts

@@ -5,109 +5,107 @@ import { Flag } from "@/flag/flag"
 import type { SessionID } from "@/session/schema"
 import { Log } from "../util"
 
-export namespace FileTime {
-  const log = Log.create({ service: "file.time" })
-
-  export type Stamp = {
-    readonly read: Date
-    readonly mtime: number | undefined
-    readonly size: number | undefined
-  }
-
-  const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
-    const value = reads.get(sessionID)
-    if (value) return value
-
-    const next = new Map<string, Stamp>()
-    reads.set(sessionID, next)
-    return next
-  }
-
-  interface State {
-    reads: Map<SessionID, Map<string, Stamp>>
-    locks: Map<string, Semaphore.Semaphore>
-  }
-
-  export interface Interface {
-    readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
-    readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
-    readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
-    readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fsys = yield* AppFileSystem.Service
-      const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
-
-      const stamp = Effect.fnUntraced(function* (file: string) {
-        const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
-        return {
-          read: yield* DateTime.nowAsDate,
-          mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
-          size: info ? Number(info.size) : undefined,
-        }
-      })
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("FileTime.state")(() =>
-          Effect.succeed({
-            reads: new Map<SessionID, Map<string, Stamp>>(),
-            locks: new Map<string, Semaphore.Semaphore>(),
-          }),
-        ),
-      )
+const log = Log.create({ service: "file.time" })
+
+export type Stamp = {
+  readonly read: Date
+  readonly mtime: number | undefined
+  readonly size: number | undefined
+}
+
+const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
+  const value = reads.get(sessionID)
+  if (value) return value
 
-      const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
-        filepath = AppFileSystem.normalizePath(filepath)
-        const locks = (yield* InstanceState.get(state)).locks
-        const lock = locks.get(filepath)
-        if (lock) return lock
-
-        const next = Semaphore.makeUnsafe(1)
-        locks.set(filepath, next)
-        return next
-      })
-
-      const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
-        file = AppFileSystem.normalizePath(file)
-        const reads = (yield* InstanceState.get(state)).reads
-        log.info("read", { sessionID, file })
-        session(reads, sessionID).set(file, yield* stamp(file))
-      })
-
-      const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
-        file = AppFileSystem.normalizePath(file)
-        const reads = (yield* InstanceState.get(state)).reads
-        return reads.get(sessionID)?.get(file)?.read
-      })
-
-      const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
-        if (disableCheck) return
-        filepath = AppFileSystem.normalizePath(filepath)
-
-        const reads = (yield* InstanceState.get(state)).reads
-        const time = reads.get(sessionID)?.get(filepath)
-        if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
-
-        const next = yield* stamp(filepath)
-        const changed = next.mtime !== time.mtime || next.size !== time.size
-        if (!changed) return
-
-        throw new Error(
-          `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
-        )
-      })
-
-      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
-        return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
-      })
-
-      return Service.of({ read, get, assert, withLock })
-    }),
-  ).pipe(Layer.orDie)
-
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+  const next = new Map<string, Stamp>()
+  reads.set(sessionID, next)
+  return next
 }
+
+interface State {
+  reads: Map<SessionID, Map<string, Stamp>>
+  locks: Map<string, Semaphore.Semaphore>
+}
+
+export interface Interface {
+  readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
+  readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
+  readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
+  readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fsys = yield* AppFileSystem.Service
+    const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
+
+    const stamp = Effect.fnUntraced(function* (file: string) {
+      const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
+      return {
+        read: yield* DateTime.nowAsDate,
+        mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
+        size: info ? Number(info.size) : undefined,
+      }
+    })
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("FileTime.state")(() =>
+        Effect.succeed({
+          reads: new Map<SessionID, Map<string, Stamp>>(),
+          locks: new Map<string, Semaphore.Semaphore>(),
+        }),
+      ),
+    )
+
+    const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
+      filepath = AppFileSystem.normalizePath(filepath)
+      const locks = (yield* InstanceState.get(state)).locks
+      const lock = locks.get(filepath)
+      if (lock) return lock
+
+      const next = Semaphore.makeUnsafe(1)
+      locks.set(filepath, next)
+      return next
+    })
+
+    const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
+      file = AppFileSystem.normalizePath(file)
+      const reads = (yield* InstanceState.get(state)).reads
+      log.info("read", { sessionID, file })
+      session(reads, sessionID).set(file, yield* stamp(file))
+    })
+
+    const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
+      file = AppFileSystem.normalizePath(file)
+      const reads = (yield* InstanceState.get(state)).reads
+      return reads.get(sessionID)?.get(file)?.read
+    })
+
+    const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
+      if (disableCheck) return
+      filepath = AppFileSystem.normalizePath(filepath)
+
+      const reads = (yield* InstanceState.get(state)).reads
+      const time = reads.get(sessionID)?.get(filepath)
+      if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
+
+      const next = yield* stamp(filepath)
+      const changed = next.mtime !== time.mtime || next.size !== time.size
+      if (!changed) return
+
+      throw new Error(
+        `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
+      )
+    })
+
+    const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
+      return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
+    })
+
+    return Service.of({ read, get, assert, withLock })
+  }),
+).pipe(Layer.orDie)
+
+export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))

+ 121 - 123
packages/opencode/src/file/watcher.ts

@@ -13,151 +13,149 @@ import { Git } from "@/git"
 import { Instance } from "@/project/instance"
 import { lazy } from "@/util/lazy"
 import { Config } from "../config"
-import { FileIgnore } from "./ignore"
-import { Protected } from "./protected"
+import * as FileIgnore from "./ignore"
+import * as Protected from "./protected"
 import { Log } from "../util"
 
 declare const OPENCODE_LIBC: string | undefined
 
-export namespace FileWatcher {
-  const log = Log.create({ service: "file.watcher" })
-  const SUBSCRIBE_TIMEOUT_MS = 10_000
+const log = Log.create({ service: "file.watcher" })
+const SUBSCRIBE_TIMEOUT_MS = 10_000
 
-  export const Event = {
-    Updated: BusEvent.define(
-      "file.watcher.updated",
-      z.object({
-        file: z.string(),
-        event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
-      }),
-    ),
+export const Event = {
+  Updated: BusEvent.define(
+    "file.watcher.updated",
+    z.object({
+      file: z.string(),
+      event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
+    }),
+  ),
+}
+
+const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
+  try {
+    const binding = require(
+      `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
+    )
+    return createWrapper(binding) as typeof import("@parcel/watcher")
+  } catch (error) {
+    log.error("failed to load watcher binding", { error })
+    return
   }
+})
 
-  const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
-    try {
-      const binding = require(
-        `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
-      )
-      return createWrapper(binding) as typeof import("@parcel/watcher")
-    } catch (error) {
-      log.error("failed to load watcher binding", { error })
-      return
-    }
-  })
+function getBackend() {
+  if (process.platform === "win32") return "windows"
+  if (process.platform === "darwin") return "fs-events"
+  if (process.platform === "linux") return "inotify"
+}
 
-  function getBackend() {
-    if (process.platform === "win32") return "windows"
-    if (process.platform === "darwin") return "fs-events"
-    if (process.platform === "linux") return "inotify"
-  }
+function protecteds(dir: string) {
+  return Protected.paths().filter((item) => {
+    const rel = path.relative(dir, item)
+    return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
+  })
+}
 
-  function protecteds(dir: string) {
-    return Protected.paths().filter((item) => {
-      const rel = path.relative(dir, item)
-      return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
-    })
-  }
+export const hasNativeBinding = () => !!watcher()
 
-  export const hasNativeBinding = () => !!watcher()
+export interface Interface {
+  readonly init: () => Effect.Effect<void>
+}
 
-  export interface Interface {
-    readonly init: () => Effect.Effect<void>
-  }
+export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
 
-  export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const config = yield* Config.Service
+    const git = yield* Git.Service
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const config = yield* Config.Service
-      const git = yield* Git.Service
+    const state = yield* InstanceState.make(
+      Effect.fn("FileWatcher.state")(
+        function* () {
+          if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
 
-      const state = yield* InstanceState.make(
-        Effect.fn("FileWatcher.state")(
-          function* () {
-            if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
+          log.info("init", { directory: Instance.directory })
 
-            log.info("init", { directory: Instance.directory })
+          const backend = getBackend()
+          if (!backend) {
+            log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
+            return
+          }
 
-            const backend = getBackend()
-            if (!backend) {
-              log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
-              return
-            }
+          const w = watcher()
+          if (!w) return
 
-            const w = watcher()
-            if (!w) return
+          log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
 
-            log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
+          const subs: ParcelWatcher.AsyncSubscription[] = []
+          yield* Effect.addFinalizer(() =>
+            Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
+          )
 
-            const subs: ParcelWatcher.AsyncSubscription[] = []
-            yield* Effect.addFinalizer(() =>
-              Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
+          const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
+            if (err) return
+            for (const evt of evts) {
+              if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
+              if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
+              if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
+            }
+          })
+
+          const subscribe = (dir: string, ignore: string[]) => {
+            const pending = w.subscribe(dir, cb, { ignore, backend })
+            return Effect.gen(function* () {
+              const sub = yield* Effect.promise(() => pending)
+              subs.push(sub)
+            }).pipe(
+              Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
+              Effect.catchCause((cause) => {
+                log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
+                pending.then((s) => s.unsubscribe()).catch(() => {})
+                return Effect.void
+              }),
             )
-
-            const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
-              if (err) return
-              for (const evt of evts) {
-                if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
-                if (evt.type === "update") void Bus.publish(Event.Updated, { file: evt.path, event: "change" })
-                if (evt.type === "delete") void Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
-              }
+          }
+
+          const cfg = yield* config.get()
+          const cfgIgnores = cfg.watcher?.ignore ?? []
+
+          if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
+            yield* subscribe(Instance.directory, [
+              ...FileIgnore.PATTERNS,
+              ...cfgIgnores,
+              ...protecteds(Instance.directory),
+            ])
+          }
+
+          if (Instance.project.vcs === "git") {
+            const result = yield* git.run(["rev-parse", "--git-dir"], {
+              cwd: Instance.project.worktree,
             })
-
-            const subscribe = (dir: string, ignore: string[]) => {
-              const pending = w.subscribe(dir, cb, { ignore, backend })
-              return Effect.gen(function* () {
-                const sub = yield* Effect.promise(() => pending)
-                subs.push(sub)
-              }).pipe(
-                Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
-                Effect.catchCause((cause) => {
-                  log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
-                  pending.then((s) => s.unsubscribe()).catch(() => {})
-                  return Effect.void
-                }),
+            const vcsDir =
+              result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
+            if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
+              const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
+                (entry) => entry !== "HEAD",
               )
+              yield* subscribe(vcsDir, ignore)
             }
-
-            const cfg = yield* config.get()
-            const cfgIgnores = cfg.watcher?.ignore ?? []
-
-            if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
-              yield* subscribe(Instance.directory, [
-                ...FileIgnore.PATTERNS,
-                ...cfgIgnores,
-                ...protecteds(Instance.directory),
-              ])
-            }
-
-            if (Instance.project.vcs === "git") {
-              const result = yield* git.run(["rev-parse", "--git-dir"], {
-                cwd: Instance.project.worktree,
-              })
-              const vcsDir =
-                result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
-              if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
-                const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
-                  (entry) => entry !== "HEAD",
-                )
-                yield* subscribe(vcsDir, ignore)
-              }
-            }
-          },
-          Effect.catchCause((cause) => {
-            log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
-            return Effect.void
-          }),
-        ),
-      )
-
-      return Service.of({
-        init: Effect.fn("FileWatcher.init")(function* () {
-          yield* InstanceState.get(state)
+          }
+        },
+        Effect.catchCause((cause) => {
+          log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
+          return Effect.void
         }),
-      })
-    }),
-  )
+      ),
+    )
 
-  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
-}
+    return Service.of({
+      init: Effect.fn("FileWatcher.init")(function* () {
+        yield* InstanceState.get(state)
+      }),
+    })
+  }),
+)
+
+export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))

+ 1 - 1
packages/opencode/src/project/bootstrap.ts

@@ -9,7 +9,7 @@ import { Bus } from "../bus"
 import { Command } from "../command"
 import { Instance } from "./instance"
 import { Log } from "@/util"
-import { FileWatcher } from "@/file/watcher"
+import { FileWatcher } from "@/file"
 import { ShareNext } from "@/share"
 import * as Effect from "effect/Effect"
 

+ 1 - 1
packages/opencode/src/project/vcs.ts

@@ -5,7 +5,7 @@ import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { InstanceState } from "@/effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { FileWatcher } from "@/file/watcher"
+import { FileWatcher } from "@/file"
 import { Git } from "@/git"
 import { Log } from "@/util"
 import { Instance } from "./instance"

+ 1 - 1
packages/opencode/src/server/instance/file.ts

@@ -4,7 +4,7 @@ import { Effect } from "effect"
 import z from "zod"
 import { AppRuntime } from "../../effect/app-runtime"
 import { File } from "../../file"
-import { Ripgrep } from "../../file/ripgrep"
+import { Ripgrep } from "../../file"
 import { LSP } from "../../lsp"
 import { Instance } from "../../project/instance"
 import { lazy } from "../../util/lazy"

+ 1 - 1
packages/opencode/src/session/prompt.ts

@@ -22,7 +22,7 @@ import MAX_STEPS from "../session/prompt/max-steps.txt"
 import { ToolRegistry } from "../tool/registry"
 import { MCP } from "../mcp"
 import { LSP } from "../lsp"
-import { FileTime } from "../file/time"
+import { FileTime } from "../file"
 import { Flag } from "../flag/flag"
 import { ulid } from "ulid"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"

+ 1 - 1
packages/opencode/src/tool/apply_patch.ts

@@ -3,7 +3,7 @@ import * as path from "path"
 import { Effect } from "effect"
 import { Tool } from "./tool"
 import { Bus } from "../bus"
-import { FileWatcher } from "../file/watcher"
+import { FileWatcher } from "../file"
 import { Instance } from "../project/instance"
 import { Patch } from "../patch"
 import { createTwoFilesPatch, diffLines } from "diff"

+ 2 - 2
packages/opencode/src/tool/edit.ts

@@ -11,10 +11,10 @@ import { LSP } from "../lsp"
 import { createTwoFilesPatch, diffLines } from "diff"
 import DESCRIPTION from "./edit.txt"
 import { File } from "../file"
-import { FileWatcher } from "../file/watcher"
+import { FileWatcher } from "../file"
 import { Bus } from "../bus"
 import { Format } from "../format"
-import { FileTime } from "../file/time"
+import { FileTime } from "../file"
 import { Instance } from "../project/instance"
 import { Snapshot } from "@/snapshot"
 import { assertExternalDirectoryEffect } from "./external-directory"

+ 1 - 1
packages/opencode/src/tool/glob.ts

@@ -4,7 +4,7 @@ import { Effect, Option } from "effect"
 import * as Stream from "effect/Stream"
 import { InstanceState } from "@/effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { Ripgrep } from "../file"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
 import { Tool } from "./tool"

+ 1 - 1
packages/opencode/src/tool/grep.ts

@@ -3,7 +3,7 @@ import z from "zod"
 import { Effect, Option } from "effect"
 import { InstanceState } from "@/effect"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { Ripgrep } from "../file"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./grep.txt"
 import { Tool } from "./tool"

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

@@ -7,7 +7,7 @@ import { createInterface } from "readline"
 import { Tool } from "./tool"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { LSP } from "../lsp"
-import { FileTime } from "../file/time"
+import { FileTime } from "../file"
 import DESCRIPTION from "./read.txt"
 import { Instance } from "../project/instance"
 import { assertExternalDirectoryEffect } from "./external-directory"

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

@@ -33,13 +33,13 @@ import { Effect, Layer, Context } from "effect"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-import { Ripgrep } from "../file/ripgrep"
+import { Ripgrep } from "../file"
 import { Format } from "../format"
 import { InstanceState } from "@/effect"
 import { Question } from "../question"
 import { Todo } from "../session/todo"
 import { LSP } from "../lsp"
-import { FileTime } from "../file/time"
+import { FileTime } from "../file"
 import { Instruction } from "../session/instruction"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Bus } from "../bus"

+ 1 - 1
packages/opencode/src/tool/skill.ts

@@ -4,7 +4,7 @@ import z from "zod"
 import { Effect } from "effect"
 import * as Stream from "effect/Stream"
 import { EffectLogger } from "@/effect"
-import { Ripgrep } from "../file/ripgrep"
+import { Ripgrep } from "../file"
 import { Skill } from "../skill"
 import { Tool } from "./tool"
 

+ 2 - 2
packages/opencode/src/tool/write.ts

@@ -7,9 +7,9 @@ import { createTwoFilesPatch } from "diff"
 import DESCRIPTION from "./write.txt"
 import { Bus } from "../bus"
 import { File } from "../file"
-import { FileWatcher } from "../file/watcher"
+import { FileWatcher } from "../file"
 import { Format } from "../format"
-import { FileTime } from "../file/time"
+import { FileTime } from "../file"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Instance } from "../project/instance"
 import { trimDiff } from "./edit"

+ 1 - 1
packages/opencode/test/file/ignore.test.ts

@@ -1,5 +1,5 @@
 import { test, expect } from "bun:test"
-import { FileIgnore } from "../../src/file/ignore"
+import { FileIgnore } from "../../src/file"
 
 test("match nested and non-nested", () => {
   expect(FileIgnore.match("node_modules/index.js")).toBe(true)

+ 1 - 1
packages/opencode/test/file/ripgrep.test.ts

@@ -4,7 +4,7 @@ import * as Stream from "effect/Stream"
 import fs from "fs/promises"
 import path from "path"
 import { tmpdir } from "../fixture/fixture"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Ripgrep } from "../../src/file"
 
 const run = <A>(effect: Effect.Effect<A, unknown, Ripgrep.Service>) =>
   effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)

+ 2 - 2
packages/opencode/test/file/time.test.ts

@@ -3,7 +3,7 @@ import fs from "fs/promises"
 import path from "path"
 import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { FileTime } from "../../src/file/time"
+import { FileTime } from "../../src/file"
 import { Instance } from "../../src/project/instance"
 import { SessionID } from "../../src/session/schema"
 import { Filesystem } from "../../src/util"
@@ -43,7 +43,7 @@ const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Eff
   throw new Error("expected file time effect to fail")
 })
 
-describe("file/time", () => {
+describe("file", () => {
   describe("read() and get()", () => {
     it.live("stores read timestamp", () =>
       provideTmpdirInstance((dir) =>

+ 1 - 1
packages/opencode/test/file/watcher.test.ts

@@ -6,7 +6,7 @@ import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from
 import { tmpdir } from "../fixture/fixture"
 import { Bus } from "../../src/bus"
 import { Config } from "../../src/config"
-import { FileWatcher } from "../../src/file/watcher"
+import { FileWatcher } from "../../src/file"
 import { Git } from "../../src/git"
 import { Instance } from "../../src/project/instance"
 

+ 1 - 1
packages/opencode/test/project/vcs.test.ts

@@ -5,7 +5,7 @@ import fs from "fs/promises"
 import path from "path"
 import { tmpdir } from "../fixture/fixture"
 import { AppRuntime } from "../../src/effect/app-runtime"
-import { FileWatcher } from "../../src/file/watcher"
+import { FileWatcher } from "../../src/file"
 import { Instance } from "../../src/project/instance"
 import { GlobalBus } from "../../src/bus/global"
 import { Vcs } from "../../src/project"

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

@@ -7,7 +7,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
 import { Bus } from "../../src/bus"
 import { Command } from "../../src/command"
 import { Config } from "../../src/config"
-import { FileTime } from "../../src/file/time"
+import { FileTime } from "../../src/file"
 import { LSP } from "../../src/lsp"
 import { MCP } from "../../src/mcp"
 import { Permission } from "../../src/permission"
@@ -38,7 +38,7 @@ import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"
 import { Log } from "../../src/util"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Ripgrep } from "../../src/file"
 import { Format } from "../../src/format"
 import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"

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

@@ -33,7 +33,7 @@ import { Agent as AgentSvc } from "../../src/agent/agent"
 import { Bus } from "../../src/bus"
 import { Command } from "../../src/command"
 import { Config } from "../../src/config"
-import { FileTime } from "../../src/file/time"
+import { FileTime } from "../../src/file"
 import { LSP } from "../../src/lsp"
 import { MCP } from "../../src/mcp"
 import { Permission } from "../../src/permission"
@@ -54,7 +54,7 @@ import { ToolRegistry } from "../../src/tool/registry"
 import { Truncate } from "../../src/tool/truncate"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Ripgrep } from "../../src/file"
 import { Format } from "../../src/format"
 
 void Log.init({ print: false })

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

@@ -5,7 +5,7 @@ import { Effect, Layer, ManagedRuntime } from "effect"
 import { EditTool } from "../../src/tool/edit"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
-import { FileTime } from "../../src/file/time"
+import { FileTime } from "../../src/file"
 import { LSP } from "../../src/lsp"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Format } from "../../src/format"
@@ -138,7 +138,7 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const { FileWatcher } = await import("../../src/file/watcher")
+          const { FileWatcher } = await import("../../src/file")
 
           const updated = await onceBus(FileWatcher.Event.Updated)
 
@@ -371,7 +371,7 @@ describe("tool.edit", () => {
         fn: async () => {
           await readFileTime(ctx.sessionID, filepath)
 
-          const { FileWatcher } = await import("../../src/file/watcher")
+          const { FileWatcher } = await import("../../src/file")
 
           const updated = await onceBus(FileWatcher.Event.Updated)
 

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

@@ -4,7 +4,7 @@ import { Cause, Effect, Exit, Layer } from "effect"
 import { GlobTool } from "../../src/tool/glob"
 import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Ripgrep } from "../../src/file"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { Truncate } from "../../src/tool/truncate"
 import { Agent } from "../../src/agent/agent"

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

@@ -7,7 +7,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { Truncate } from "../../src/tool/truncate"
 import { Agent } from "../../src/agent/agent"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Ripgrep } from "../../src/file"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
 import { testEffect } from "../lib/effect"
 

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

@@ -4,7 +4,7 @@ import path from "path"
 import { Agent } from "../../src/agent/agent"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { FileTime } from "../../src/file/time"
+import { FileTime } from "../../src/file"
 import { LSP } from "../../src/lsp"
 import { Permission } from "../../src/permission"
 import { Instance } from "../../src/project/instance"

+ 1 - 1
packages/opencode/test/tool/write.test.ts

@@ -6,7 +6,7 @@ import { WriteTool } from "../../src/tool/write"
 import { Instance } from "../../src/project/instance"
 import { LSP } from "../../src/lsp"
 import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { FileTime } from "../../src/file/time"
+import { FileTime } from "../../src/file"
 import { Bus } from "../../src/bus"
 import { Format } from "../../src/format"
 import { Truncate } from "../../src/tool/truncate"