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

refactor(core): consolidate filesystem services (#30447)

Dax 4 месяцев назад
Родитель
Сommit
604a5f781f
100 измененных файлов с 2242 добавлено и 2495 удалено
  1. 13 11
      bun.lock
  2. 2 2
      packages/app/src/pages/layout.tsx
  3. 13 0
      packages/core/package.json
  4. 3 3
      packages/core/src/auth.ts
  5. 2 2
      packages/core/src/config.ts
  6. 251 229
      packages/core/src/filesystem.ts
  7. 6 20
      packages/core/src/filesystem/ignore.ts
  8. 4 10
      packages/core/src/filesystem/protected.ts
  9. 11 11
      packages/core/src/filesystem/ripgrep.ts
  10. 140 0
      packages/core/src/filesystem/watcher.ts
  11. 247 0
      packages/core/src/fs-util.ts
  12. 12 7
      packages/core/src/git.ts
  13. 0 194
      packages/core/src/location-filesystem.ts
  14. 6 4
      packages/core/src/location-layer.ts
  15. 3 3
      packages/core/src/models-dev.ts
  16. 3 3
      packages/core/src/npm.ts
  17. 5 5
      packages/core/src/project-reference.ts
  18. 3 3
      packages/core/src/project.ts
  19. 118 115
      packages/core/src/repository-cache.ts
  20. 4 4
      packages/core/src/util/effect-flock.ts
  21. 1 1
      packages/core/src/util/which.ts
  22. 2 2
      packages/core/test/account.test.ts
  23. 2 2
      packages/core/test/config/config.test.ts
  24. 34 34
      packages/core/test/filesystem/filesystem.test.ts
  25. 10 0
      packages/core/test/filesystem/ignore.test.ts
  26. 1 1
      packages/core/test/filesystem/ripgrep.test.ts
  27. 272 0
      packages/core/test/filesystem/watcher.test.ts
  28. 2 2
      packages/core/test/fixture/effect-flock-worker.ts
  29. 51 13
      packages/core/test/location-filesystem.test.ts
  30. 2 2
      packages/core/test/location-layer.test.ts
  31. 2 2
      packages/core/test/models.test.ts
  32. 2 2
      packages/core/test/npm.test.ts
  33. 2 2
      packages/core/test/project-reference.test.ts
  34. 2 2
      packages/core/test/repository-cache.test.ts
  35. 2 2
      packages/core/test/util/effect-flock.test.ts
  36. 2 2
      packages/core/test/util/which.test.ts
  37. 3 3
      packages/opencode/BUN_SHELL_MIGRATION_PLAN.md
  38. 0 11
      packages/opencode/package.json
  39. 4 4
      packages/opencode/specs/effect/facades.md
  40. 1 1
      packages/opencode/specs/effect/guide.md
  41. 1 1
      packages/opencode/specs/effect/loose-ends.md
  42. 1 1
      packages/opencode/specs/effect/migration.md
  43. 2 2
      packages/opencode/specs/effect/todo.md
  44. 3 3
      packages/opencode/specs/effect/tools.md
  45. 3 3
      packages/opencode/src/auth/index.ts
  46. 14 17
      packages/opencode/src/cli/cmd/debug/file.ts
  47. 1 1
      packages/opencode/src/cli/cmd/debug/ripgrep.ts
  48. 2 2
      packages/opencode/src/cli/cmd/import.ts
  49. 4 4
      packages/opencode/src/cli/cmd/run/variant.shared.ts
  50. 1 1
      packages/opencode/src/cli/cmd/session.ts
  51. 3 3
      packages/opencode/src/cli/cmd/tui/config/tui.ts
  52. 1 1
      packages/opencode/src/cli/cmd/tui/util/clipboard.ts
  53. 4 4
      packages/opencode/src/config/config.ts
  54. 3 3
      packages/opencode/src/config/paths.ts
  55. 3 3
      packages/opencode/src/control-plane/workspace.ts
  56. 3 7
      packages/opencode/src/effect/app-runtime.ts
  57. 0 4
      packages/opencode/src/effect/bootstrap-runtime.ts
  58. 0 654
      packages/opencode/src/file/index.ts
  59. 0 172
      packages/opencode/src/file/watcher.ts
  60. 1 1
      packages/opencode/src/format/formatter.ts
  61. 1 1
      packages/opencode/src/lsp/server.ts
  62. 3 6
      packages/opencode/src/mcp/auth.ts
  63. 2 2
      packages/opencode/src/mcp/index.ts
  64. 4 4
      packages/opencode/src/patch/index.ts
  65. 1 7
      packages/opencode/src/project/bootstrap.ts
  66. 3 3
      packages/opencode/src/project/instance-context.ts
  67. 4 4
      packages/opencode/src/project/instance-store.ts
  68. 5 5
      packages/opencode/src/project/project.ts
  69. 3 3
      packages/opencode/src/project/vcs.ts
  70. 3 3
      packages/opencode/src/provider/provider.ts
  71. 3 3
      packages/opencode/src/reference/reference.ts
  72. 5 5
      packages/opencode/src/reference/repository-cache.ts
  73. 47 5
      packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
  74. 3 3
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts
  75. 2 2
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/location.ts
  76. 54 14
      packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts
  77. 3 3
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts
  78. 4 8
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  79. 4 4
      packages/opencode/src/server/shared/ui.ts
  80. 8 8
      packages/opencode/src/session/instruction.ts
  81. 6 6
      packages/opencode/src/session/prompt.ts
  82. 2 2
      packages/opencode/src/session/reminders.ts
  83. 1 1
      packages/opencode/src/shell/shell.ts
  84. 80 81
      packages/opencode/src/skill/discovery.ts
  85. 4 4
      packages/opencode/src/skill/index.ts
  86. 624 627
      packages/opencode/src/snapshot/index.ts
  87. 12 16
      packages/opencode/src/storage/storage.ts
  88. 7 7
      packages/opencode/src/tool/apply_patch.ts
  89. 10 10
      packages/opencode/src/tool/edit.ts
  90. 3 3
      packages/opencode/src/tool/external-directory.ts
  91. 3 3
      packages/opencode/src/tool/glob.ts
  92. 5 7
      packages/opencode/src/tool/grep.ts
  93. 2 2
      packages/opencode/src/tool/lsp.ts
  94. 4 4
      packages/opencode/src/tool/read.ts
  95. 4 4
      packages/opencode/src/tool/registry.ts
  96. 6 6
      packages/opencode/src/tool/shell.ts
  97. 1 1
      packages/opencode/src/tool/skill.ts
  98. 3 3
      packages/opencode/src/tool/truncate.ts
  99. 7 7
      packages/opencode/src/tool/write.ts
  100. 3 7
      packages/opencode/src/util/bom.ts

+ 13 - 11
bun.lock

@@ -269,13 +269,16 @@
         "@opentelemetry/context-async-hooks": "2.6.1",
         "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
         "@opentelemetry/sdk-trace-base": "2.6.1",
+        "@parcel/watcher": "2.5.1",
         "ai-gateway-provider": "3.1.2",
         "cross-spawn": "catalog:",
         "drizzle-orm": "catalog:",
         "effect": "catalog:",
+        "fuzzysort": "3.1.0",
         "gitlab-ai-provider": "6.8.0",
         "glob": "13.0.5",
         "google-auth-library": "10.5.0",
+        "ignore": "7.0.5",
         "immer": "11.1.4",
         "jsonc-parser": "3.3.1",
         "mime-types": "3.0.2",
@@ -283,10 +286,19 @@
         "npm-package-arg": "13.0.2",
         "semver": "^7.6.3",
         "venice-ai-sdk-provider": "2.0.2",
+        "which": "6.0.1",
         "xdg-basedir": "5.1.0",
         "zod": "catalog:",
       },
       "devDependencies": {
+        "@parcel/watcher-darwin-arm64": "2.5.1",
+        "@parcel/watcher-darwin-x64": "2.5.1",
+        "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+        "@parcel/watcher-linux-arm64-musl": "2.5.1",
+        "@parcel/watcher-linux-x64-glibc": "2.5.1",
+        "@parcel/watcher-linux-x64-musl": "2.5.1",
+        "@parcel/watcher-win32-arm64": "2.5.1",
+        "@parcel/watcher-win32-x64": "2.5.1",
         "@tsconfig/bun": "catalog:",
         "@types/bun": "catalog:",
         "@types/cross-spawn": "catalog:",
@@ -294,6 +306,7 @@
         "@types/npm-package-arg": "6.1.4",
         "@types/npmcli__arborist": "6.3.3",
         "@types/semver": "catalog:",
+        "@types/which": "3.0.4",
         "drizzle-kit": "catalog:",
       },
     },
@@ -533,7 +546,6 @@
         "google-auth-library": "10.5.0",
         "gray-matter": "4.0.3",
         "htmlparser2": "8.0.2",
-        "ignore": "7.0.5",
         "immer": "11.1.4",
         "jsonc-parser": "3.3.1",
         "mime-types": "3.0.2",
@@ -555,7 +567,6 @@
         "venice-ai-sdk-provider": "2.0.2",
         "vscode-jsonrpc": "8.2.1",
         "web-tree-sitter": "0.25.10",
-        "which": "6.0.1",
         "ws": "8.21.0",
         "xdg-basedir": "5.1.0",
         "yargs": "18.0.0",
@@ -567,14 +578,6 @@
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/http-recorder": "workspace:*",
         "@opencode-ai/script": "workspace:*",
-        "@parcel/watcher-darwin-arm64": "2.5.1",
-        "@parcel/watcher-darwin-x64": "2.5.1",
-        "@parcel/watcher-linux-arm64-glibc": "2.5.1",
-        "@parcel/watcher-linux-arm64-musl": "2.5.1",
-        "@parcel/watcher-linux-x64-glibc": "2.5.1",
-        "@parcel/watcher-linux-x64-musl": "2.5.1",
-        "@parcel/watcher-win32-arm64": "2.5.1",
-        "@parcel/watcher-win32-x64": "2.5.1",
         "@standard-schema/spec": "1.0.0",
         "@tsconfig/bun": "catalog:",
         "@types/babel__core": "7.20.5",
@@ -584,7 +587,6 @@
         "@types/npm-package-arg": "6.1.4",
         "@types/semver": "^7.5.8",
         "@types/turndown": "5.0.5",
-        "@types/which": "3.0.4",
         "@types/yargs": "17.0.33",
         "@typescript/native-preview": "catalog:",
         "drizzle-orm": "catalog:",

+ 2 - 2
packages/app/src/pages/layout.tsx

@@ -1644,7 +1644,7 @@ export default function Layout(props: ParentProps) {
     })
 
     onMount(() => {
-      serverSDK.client.file
+      serverSDK.client.vcs
         .status({ directory: props.directory })
         .then((x) => {
           const files = x.data ?? []
@@ -1712,7 +1712,7 @@ export default function Layout(props: ParentProps) {
     }
 
     onMount(() => {
-      serverSDK.client.file
+      serverSDK.client.vcs
         .status({ directory: props.directory })
         .then((x) => {
           const files = x.data ?? []

+ 13 - 0
packages/core/package.json

@@ -33,6 +33,15 @@
     "@types/npm-package-arg": "6.1.4",
     "@types/npmcli__arborist": "6.3.3",
     "@types/semver": "catalog:",
+    "@types/which": "3.0.4",
+    "@parcel/watcher-darwin-arm64": "2.5.1",
+    "@parcel/watcher-darwin-x64": "2.5.1",
+    "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+    "@parcel/watcher-linux-arm64-musl": "2.5.1",
+    "@parcel/watcher-linux-x64-glibc": "2.5.1",
+    "@parcel/watcher-linux-x64-musl": "2.5.1",
+    "@parcel/watcher-win32-arm64": "2.5.1",
+    "@parcel/watcher-win32-x64": "2.5.1",
     "drizzle-kit": "catalog:"
   },
   "dependencies": {
@@ -68,21 +77,25 @@
     "@opentelemetry/context-async-hooks": "2.6.1",
     "@opentelemetry/exporter-trace-otlp-http": "0.214.0",
     "@opentelemetry/sdk-trace-base": "2.6.1",
+    "@parcel/watcher": "2.5.1",
     "@openrouter/ai-sdk-provider": "2.8.1",
     "ai-gateway-provider": "3.1.2",
     "cross-spawn": "catalog:",
     "drizzle-orm": "catalog:",
     "effect": "catalog:",
+    "fuzzysort": "3.1.0",
     "gitlab-ai-provider": "6.8.0",
     "glob": "13.0.5",
     "google-auth-library": "10.5.0",
     "immer": "11.1.4",
+    "ignore": "7.0.5",
     "jsonc-parser": "3.3.1",
     "mime-types": "3.0.2",
     "minimatch": "10.2.5",
     "npm-package-arg": "13.0.2",
     "semver": "^7.6.3",
     "venice-ai-sdk-provider": "2.0.2",
+    "which": "6.0.1",
     "xdg-basedir": "5.1.0",
     "zod": "catalog:"
   },

+ 3 - 3
packages/core/src/auth.ts

@@ -5,7 +5,7 @@ import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
 import { Identifier } from "./util/identifier"
 import { NonNegativeInt, withStatics } from "./schema"
 import { Global } from "./global"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { EventV2 } from "./event"
 
 export const ID = Schema.String.pipe(
@@ -131,7 +131,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fsys = yield* AppFileSystem.Service
+    const fsys = yield* FSUtil.Service
     const global = yield* Global.Service
     const events = yield* EventV2.Service
     const file = path.join(global.data, "account.json")
@@ -334,7 +334,7 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer.pipe(
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Global.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
 )

+ 2 - 2
packages/core/src/config.ts

@@ -3,7 +3,7 @@ export * as Config from "./config"
 import path from "path"
 import { type ParseError, parse } from "jsonc-parser"
 import { Context, Effect, Layer, Option, Schema } from "effect"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { Global } from "./global"
 import { Location } from "./location"
 import { PermissionV2 } from "./permission"
@@ -127,7 +127,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const global = yield* Global.Service
     const location = yield* Location.Service
     const policy = yield* Policy.Service

+ 251 - 229
packages/core/src/filesystem.ts

@@ -1,247 +1,269 @@
-import { NodeFileSystem } from "@effect/platform-node"
-import { dirname, join, relative, resolve as pathResolve } from "path"
-import { realpathSync } from "fs"
-import * as NFS from "fs/promises"
-import { lookup } from "mime-types"
-import { Context, Effect, FileSystem, Layer, Schema } from "effect"
-import type { PlatformError } from "effect/PlatformError"
-import { Glob } from "./util/glob"
-import { serviceUse } from "./effect/service-use"
-
-export namespace AppFileSystem {
-  export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
-    method: Schema.String,
-    cause: Schema.optional(Schema.Defect),
-  }) {}
-
-  export type Error = PlatformError | FileSystemError
-
-  export interface DirEntry {
-    readonly name: string
-    readonly type: "file" | "directory" | "symlink" | "other"
-  }
-
-  export interface Interface extends FileSystem.FileSystem {
-    readonly isDir: (path: string) => Effect.Effect<boolean>
-    readonly isFile: (path: string) => Effect.Effect<boolean>
-    readonly existsSafe: (path: string) => Effect.Effect<boolean>
-    readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
-    readonly readJson: (path: string) => Effect.Effect<unknown, Error>
-    readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
-    readonly ensureDir: (path: string) => Effect.Effect<void, Error>
-    readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
-    readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
-    readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
-    readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
-    readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
-    readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
-    readonly globMatch: (pattern: string, filepath: string) => boolean
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
-
-  export const use = serviceUse(Service)
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* FileSystem.FileSystem
-
-      const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
-        return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
-      })
+export * as FileSystem from "./filesystem"
 
-      const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
-        return yield* fs
-          .readFileString(path)
-          .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
-      })
+import path from "path"
+import { pathToFileURL } from "url"
+import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import { Context, Effect, Layer, Schema, Stream } from "effect"
+import { EventV2 } from "./event"
+import { FSUtil } from "./fs-util"
+import { Global } from "./global"
+import { Location } from "./location"
+import { ProjectReference } from "./project-reference"
+import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
+import { Protected } from "./filesystem/protected"
+import { Ripgrep } from "./filesystem/ripgrep"
 
-      const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
-        const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
-        return info?.type === "Directory"
-      })
+export const ReadInput = Schema.Struct({
+  path: RelativePath,
+  reference: Schema.String.pipe(Schema.optional),
+})
+export type ReadInput = typeof ReadInput.Type
 
-      const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
-        const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
-        return info?.type === "File"
-      })
+export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
+  type: Schema.Literal("text"),
+  content: Schema.String,
+  mime: Schema.String,
+}) {}
 
-      const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
-        return yield* Effect.tryPromise({
-          try: async () => {
-            const entries = await NFS.readdir(dirPath, { withFileTypes: true })
-            return entries.map(
-              (e): DirEntry => ({
-                name: e.name,
-                type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
-              }),
-            )
-          },
-          catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
-        })
-      })
+export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
+  type: Schema.Literal("binary"),
+  content: Schema.String,
+  encoding: Schema.Literal("base64"),
+  mime: Schema.String,
+}) {}
 
-      const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
-        const text = yield* fs.readFileString(path)
-        return JSON.parse(text)
-      })
+export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
+export type Content = typeof Content.Type
 
-      const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
-        const content = JSON.stringify(data, null, 2)
-        yield* fs.writeFileString(path, content)
-        if (mode) yield* fs.chmod(path, mode)
-      })
+export const ListInput = Schema.Struct({
+  path: RelativePath.pipe(Schema.optional),
+  reference: Schema.String.pipe(Schema.optional),
+})
+export type ListInput = typeof ListInput.Type
 
-      const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
-        yield* fs.makeDirectory(path, { recursive: true })
-      })
+export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
+  path: RelativePath,
+  uri: Schema.String,
+  type: Schema.Literals(["file", "directory"]),
+  mime: Schema.String,
+}) {}
 
-      const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
-        path: string,
-        content: string | Uint8Array,
-        mode?: number,
-      ) {
-        const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
-
-        yield* write.pipe(
-          Effect.catchIf(
-            (e) => e.reason._tag === "NotFound",
-            () =>
-              Effect.gen(function* () {
-                yield* fs.makeDirectory(dirname(path), { recursive: true })
-                yield* write
-              }),
-          ),
-        )
-        if (mode) yield* fs.chmod(path, mode)
-      })
+export const FindInput = Schema.Struct({
+  query: Schema.String,
+  type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
+  limit: PositiveInt.pipe(Schema.optional),
+})
+export type FindInput = typeof FindInput.Type
 
-      const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
-        return yield* Effect.tryPromise({
-          try: () => Glob.scan(pattern, options),
-          catch: (cause) => new FileSystemError({ method: "glob", cause }),
-        })
-      })
+export const GrepInput = Schema.Struct({
+  pattern: Schema.String,
+  include: Schema.String.pipe(Schema.optional),
+  limit: PositiveInt.pipe(Schema.optional),
+})
+export type GrepInput = typeof GrepInput.Type
 
-      const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
-        const result: string[] = []
-        let current = start
-        while (true) {
-          const search = join(current, target)
-          if (yield* fs.exists(search)) result.push(search)
-          if (stop === current) break
-          const parent = dirname(current)
-          if (parent === current) break
-          current = parent
-        }
-        return result
+export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
+  path: RelativePath,
+  lines: Schema.String,
+  line: PositiveInt,
+  offset: NonNegativeInt,
+  submatches: Schema.Array(
+    Schema.Struct({
+      text: Schema.String,
+      start: NonNegativeInt,
+      end: NonNegativeInt,
+    }),
+  ),
+}) {}
+
+export const Event = {
+  Edited: EventV2.define({
+    type: "file.edited",
+    schema: {
+      file: Schema.String,
+    },
+  }),
+}
+
+export interface Interface {
+  readonly read: (input: ReadInput) => Effect.Effect<Content>
+  readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
+  readonly find: (input: FindInput) => Effect.Effect<Entry[]>
+  readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
+  readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* FSUtil.Service
+    const location = yield* Location.Service
+    const references = yield* ProjectReference.Service
+    const ripgrep = yield* Ripgrep.Service
+    const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
+    const ignored = ignore()
+    const gitignore = yield* fs
+      .readFileString(path.join(location.project.directory, ".gitignore"))
+      .pipe(Effect.catch(() => Effect.succeed("")))
+    if (gitignore) ignored.add(gitignore)
+    const ignorefile = yield* fs
+      .readFileString(path.join(location.project.directory, ".ignore"))
+      .pipe(Effect.catch(() => Effect.succeed("")))
+    if (ignorefile) ignored.add(ignorefile)
+    const select = Effect.fnUntraced(function* (reference?: string) {
+      if (!reference) return { directory: location.directory, root }
+      const resolved = yield* references.get(reference)
+      if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
+      if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
+      if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
+      return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
+    })
+    const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
+      if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
+      const selected = yield* select(reference)
+      const absolute = path.resolve(selected.directory, input ?? ".")
+      if (!FSUtil.contains(selected.directory, absolute))
+        return yield* Effect.die(new Error("Path escapes the location"))
+      const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
+      if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
+      return { absolute, real, ...selected }
+    })
+    const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
+      const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
+      if (!real) return
+      if (!FSUtil.contains(selected.root, real)) return
+      const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
+      if (!info) return
+      const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
+      if (!type) return
+      return new Entry({
+        path: RelativePath.make(path.relative(selected.directory, absolute)),
+        uri: pathToFileURL(real).href,
+        type,
+        mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
       })
+    })
 
-      const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
-        const result: string[] = []
-        let current = options.start
+    const scan = Effect.fnUntraced(function* () {
+      if (location.directory === Global.Path.home && location.project.id === "global") {
+        const protectedNames = Protected.names()
+        const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+        return (yield* Effect.forEach(
+          yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
+          (item) =>
+            Effect.gen(function* () {
+              if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
+              const directory = path.join(location.directory, item.name)
+              return [
+                item.name + "/",
+                ...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
+                  child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
+                    ? [`${item.name}/${child.name}/`]
+                    : [],
+                ),
+              ]
+            }),
+        )).flat()
+      }
+
+      const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
+      const dirs = new Set<string>()
+      for (const file of files) {
+        let current = file
         while (true) {
-          for (const target of options.targets) {
-            const search = join(current, target)
-            if (yield* fs.exists(search)) result.push(search)
-          }
-          if (options.stop === current) break
-          const parent = dirname(current)
-          if (parent === current) break
-          current = parent
+          const directory = path.dirname(current)
+          if (directory === "." || directory === current) break
+          current = directory
+          dirs.add(directory + "/")
         }
-        return result
-      })
+      }
+      return [...files, ...dirs]
+    })
 
-      const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
-        const result: string[] = []
-        let current = start
-        while (true) {
-          const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
-            Effect.catch(() => Effect.succeed([] as string[])),
+    return Service.of({
+      read: Effect.fn("FileSystem.read")(function* (input) {
+        const file = yield* resolve(input.path, input.reference)
+        const info = yield* fs.stat(file.real).pipe(Effect.orDie)
+        if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
+        const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
+        const mime = FSUtil.mimeType(file.real)
+        if (!bytes.includes(0)) {
+          const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
+            Effect.option,
           )
-          result.push(...matches)
-          if (stop === current) break
-          const parent = dirname(current)
-          if (parent === current) break
-          current = parent
+          if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
         }
-        return result
-      })
+        return new BinaryContent({
+          type: "binary",
+          content: Buffer.from(bytes).toString("base64"),
+          encoding: "base64",
+          mime,
+        })
+      }),
+      list: Effect.fn("FileSystem.list")(function* (input = {}) {
+        const directory = yield* resolve(input.path, input.reference)
+        const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
+        if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
+        return yield* fs.readDirectoryEntries(directory.real).pipe(
+          Effect.orDie,
+          Effect.flatMap((items) =>
+            Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
+              concurrency: "unbounded",
+            }),
+          ),
+          Effect.map((items) =>
+            items
+              .filter((item): item is Entry => item !== undefined)
+              .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
+          ),
+        )
+      }),
+      find: Effect.fn("FileSystem.find")(function* (input) {
+        const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
+        const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
+        const sorted = input.query.trim()
+          ? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
+          : filtered.slice(0, input.limit)
+        return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
+          Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
+        )
+      }),
+      grep: Effect.fn("FileSystem.grep")(function* (input) {
+        return (yield* ripgrep
+          .search({
+            cwd: location.directory,
+            pattern: input.pattern,
+            glob: input.include ? [input.include] : undefined,
+            limit: input.limit,
+          })
+          .pipe(Effect.orDie)).items.map(
+          (item) =>
+            new GrepMatch({
+              path: RelativePath.make(item.path.text),
+              lines: item.lines.text,
+              line: item.line_number,
+              offset: item.absolute_offset,
+              submatches: item.submatches.map((submatch) => ({
+                text: submatch.match.text,
+                start: submatch.start,
+                end: submatch.end,
+              })),
+            }),
+        )
+      }),
+      isIgnored: (input, type) =>
+        ignored.ignores(
+          path.relative(location.project.directory, path.join(location.directory, input)) +
+            (type === "directory" ? "/" : ""),
+        ),
+    })
+  }),
+)
 
-      return Service.of({
-        ...fs,
-        existsSafe,
-        readFileStringSafe,
-        isDir,
-        isFile,
-        readDirectoryEntries,
-        readJson,
-        writeJson,
-        ensureDir,
-        writeWithDirs,
-        findUp,
-        up,
-        globUp,
-        glob,
-        globMatch: Glob.match,
-      })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
-
-  // Pure helpers that don't need Effect (path manipulation, sync operations)
-  export function mimeType(p: string): string {
-    return lookup(p) || "application/octet-stream"
-  }
-
-  export function normalizePath(p: string): string {
-    if (process.platform !== "win32") return p
-    const resolved = pathResolve(windowsPath(p))
-    try {
-      return realpathSync.native(resolved)
-    } catch {
-      return resolved
-    }
-  }
-
-  export function normalizePathPattern(p: string): string {
-    if (process.platform !== "win32") return p
-    if (p === "*") return p
-    const match = p.match(/^(.*)[\\/]\*$/)
-    if (!match) return normalizePath(p)
-    const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
-    return join(normalizePath(dir), "*")
-  }
-
-  export function resolve(p: string): string {
-    const resolved = pathResolve(windowsPath(p))
-    try {
-      return normalizePath(realpathSync(resolved))
-    } catch (e: any) {
-      if (e?.code === "ENOENT") return normalizePath(resolved)
-      throw e
-    }
-  }
-
-  export function windowsPath(p: string): string {
-    if (process.platform !== "win32") return p
-    return p
-      .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
-      .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
-      .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
-      .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
-  }
-
-  export function overlaps(a: string, b: string) {
-    const relA = relative(a, b)
-    const relB = relative(b, a)
-    return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
-  }
-
-  export function contains(parent: string, child: string) {
-    return !relative(parent, child).startsWith("..")
-  }
-}
+export const locationLayer = layer.pipe(
+  Layer.provide(Ripgrep.defaultLayer),
+  Layer.provideMerge(ProjectReference.locationLayer),
+)

+ 6 - 20
packages/opencode/src/file/ignore.ts → packages/core/src/filesystem/ignore.ts

@@ -1,4 +1,4 @@
-import { Glob } from "@opencode-ai/core/util/glob"
+import { Glob } from "../util/glob"
 
 const FOLDERS = new Set([
   "node_modules",
@@ -34,48 +34,34 @@ const FOLDERS = new Set([
 const FILES = [
   "**/*.swp",
   "**/*.swo",
-
   "**/*.pyc",
-
-  // OS
   "**/.DS_Store",
   "**/Thumbs.db",
-
-  // Logs & temp
   "**/logs/**",
   "**/tmp/**",
   "**/temp/**",
   "**/*.log",
-
-  // Coverage/test outputs
   "**/coverage/**",
   "**/.nyc_output/**",
 ]
 
 export const PATTERNS = [...FILES, ...FOLDERS]
 
-export function match(
-  filepath: string,
-  opts?: {
-    extra?: string[]
-    whitelist?: string[]
-  },
-) {
+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
+  for (const part of parts) {
+    if (FOLDERS.has(part)) return true
   }
 
-  const extra = opts?.extra || []
-  for (const pattern of [...FILES, ...extra]) {
+  for (const pattern of [...FILES, ...(opts?.extra || [])]) {
     if (Glob.match(pattern, filepath)) return true
   }
 
   return false
 }
 
-export * as FileIgnore from "./ignore"
+export * as Ignore from "./ignore"

+ 4 - 10
packages/opencode/src/file/protected.ts → packages/core/src/filesystem/protected.ts

@@ -1,20 +1,15 @@
-import path from "path"
 import os from "os"
+import path from "path"
 
 const home = os.homedir()
 
-// macOS directories that trigger TCC (Transparency, Consent, and Control)
-// permission prompts when accessed by a non-sandboxed process.
 const DARWIN_HOME = [
-  // Media
   "Music",
   "Pictures",
   "Movies",
-  // User-managed folders synced via iCloud / subject to TCC
   "Downloads",
   "Desktop",
   "Documents",
-  // Other system-managed
   "Public",
   "Applications",
   "Library",
@@ -34,7 +29,6 @@ const DARWIN_LIBRARY = [
 ]
 
 const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
-
 const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
 
 /** Directory basenames to skip when scanning the home directory. */
@@ -48,11 +42,11 @@ export function names(): ReadonlySet<string> {
 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_HOME.map((name) => path.join(home, name)),
+      ...DARWIN_LIBRARY.map((name) => path.join(home, "Library", name)),
       ...DARWIN_ROOT,
     ]
-  if (process.platform === "win32") return WIN32_HOME.map((n) => path.join(home, n))
+  if (process.platform === "win32") return WIN32_HOME.map((name) => path.join(home, name))
   return []
 }
 

+ 11 - 11
packages/opencode/src/file/ripgrep.ts → packages/core/src/filesystem/ripgrep.ts

@@ -1,18 +1,18 @@
 import path from "path"
-import { serviceUse } from "@opencode-ai/core/effect/service-use"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { serviceUse } from "../effect/service-use"
+import { FSUtil } from "../fs-util"
 import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
 import type { PlatformError } from "effect/PlatformError"
 import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { ChildProcess } from "effect/unstable/process"
 import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 
-import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Global } from "@opencode-ai/core/global"
-import * as Log from "@opencode-ai/core/util/log"
-import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
-import { which } from "@/util/which"
-import { NonNegativeInt } from "@opencode-ai/core/schema"
+import { CrossSpawnSpawner } from "../cross-spawn-spawner"
+import { Global } from "../global"
+import { NonNegativeInt } from "../schema"
+import * as Log from "../util/log"
+import { sanitizedProcessEnv } from "../util/opencode-process"
+import { which } from "../util/which"
 
 const log = Log.create({ service: "ripgrep" })
 const VERSION = "15.1.0"
@@ -224,11 +224,11 @@ function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal
   return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
 }
 
-export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner | HttpClient.HttpClient> =
+export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
   Layer.effect(
     Service,
     Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
+      const fs = yield* FSUtil.Service
       const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
       const spawner = yield* ChildProcessSpawner
 
@@ -477,7 +477,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildPro
 
 export const defaultLayer = layer.pipe(
   Layer.provide(FetchHttpClient.layer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
 )
 

+ 140 - 0
packages/core/src/filesystem/watcher.ts

@@ -0,0 +1,140 @@
+export * as Watcher from "./watcher"
+
+// @ts-ignore
+import { createWrapper } from "@parcel/watcher/wrapper"
+import type ParcelWatcher from "@parcel/watcher"
+import { Cause, Context, Effect, Layer, Schema } from "effect"
+import path from "path"
+import { Config } from "../config"
+import { EventV2 } from "../event"
+import { Flag } from "../flag/flag"
+import { FSUtil } from "../fs-util"
+import { Git } from "../git"
+import { Location } from "../location"
+import { lazy } from "../util/lazy"
+import * as Log from "../util/log"
+import { Ignore } from "./ignore"
+import { Protected } from "./protected"
+
+declare const OPENCODE_LIBC: string | undefined
+
+const log = Log.create({ service: "file.watcher" })
+const SUBSCRIBE_TIMEOUT_MS = 10_000
+
+export const Event = {
+  Updated: EventV2.define({
+    type: "file.watcher.updated",
+    schema: {
+      file: Schema.String,
+      event: Schema.Literals(["add", "change", "unlink"]),
+    },
+  }),
+}
+
+const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
+  try {
+    const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
+    const binding = require(
+      `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${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 protecteds(dir: string) {
+  return Protected.paths().filter((item) => {
+    const relative = path.relative(dir, item)
+    return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
+  })
+}
+
+export const hasNativeBinding = () => !!watcher()
+
+export interface Interface {}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
+
+    const backend = getBackend()
+    const location = yield* Location.Service
+    if (!backend) {
+      log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
+      return Service.of({})
+    }
+
+    const w = watcher()
+    if (!w) return Service.of({})
+
+    log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
+    const events = yield* EventV2.Service
+    const fs = yield* FSUtil.Service
+    const git = yield* Git.Service
+    const context = yield* Effect.context()
+    const runFork = Effect.runForkWith(context)
+    const subscriptions: ParcelWatcher.AsyncSubscription[] = []
+    yield* Effect.addFinalizer(() =>
+      Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
+    )
+
+    const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
+      for (const update of updates) {
+        if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
+        if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
+        if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
+      }
+    }
+
+    const subscribe = (directory: string, ignore: string[]) => {
+      const pending = w.subscribe(directory, callback, { ignore, backend })
+      return Effect.promise(() => pending).pipe(
+        Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
+        Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
+        Effect.catchCause((cause) => {
+          log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
+          pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
+          return Effect.void
+        }),
+      )
+    }
+
+    const config = (yield* (yield* Config.Service).get()).flatMap((item) => item.info.watcher?.ignore ?? [])
+    if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
+      yield* Effect.forkScoped(
+        subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
+      )
+    }
+
+    if (location.vcs?.type === "git") {
+      const resolved = yield* git.dir(location.directory)
+      const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
+      if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
+        const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
+          (entry) => (entry.name === "HEAD" ? [] : [entry.name]),
+        )
+        yield* Effect.forkScoped(subscribe(vcs, ignore))
+      }
+    }
+
+    return Service.of({})
+  }).pipe(
+    Effect.catchCause((cause) => {
+      log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
+      return Effect.succeed(Service.of({}))
+    }),
+  ),
+)
+
+export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))

+ 247 - 0
packages/core/src/fs-util.ts

@@ -0,0 +1,247 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { dirname, join, relative, resolve as pathResolve } from "path"
+import { realpathSync } from "fs"
+import * as NFS from "fs/promises"
+import { lookup } from "mime-types"
+import { Context, Effect, FileSystem, Layer, Schema } from "effect"
+import type { PlatformError } from "effect/PlatformError"
+import { Glob } from "./util/glob"
+import { serviceUse } from "./effect/service-use"
+
+export namespace FSUtil {
+  export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
+    method: Schema.String,
+    cause: Schema.optional(Schema.Defect),
+  }) {}
+
+  export type Error = PlatformError | FileSystemError
+
+  export interface DirEntry {
+    readonly name: string
+    readonly type: "file" | "directory" | "symlink" | "other"
+  }
+
+  export interface Interface extends FileSystem.FileSystem {
+    readonly isDir: (path: string) => Effect.Effect<boolean>
+    readonly isFile: (path: string) => Effect.Effect<boolean>
+    readonly existsSafe: (path: string) => Effect.Effect<boolean>
+    readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
+    readonly readJson: (path: string) => Effect.Effect<unknown, Error>
+    readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
+    readonly ensureDir: (path: string) => Effect.Effect<void, Error>
+    readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
+    readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
+    readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
+    readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
+    readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
+    readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
+    readonly globMatch: (pattern: string, filepath: string) => boolean
+  }
+
+  export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
+
+  export const use = serviceUse(Service)
+
+  export const layer = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* FileSystem.FileSystem
+
+      const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
+        return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
+      })
+
+      const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
+        return yield* fs
+          .readFileString(path)
+          .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
+      })
+
+      const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
+        const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
+        return info?.type === "Directory"
+      })
+
+      const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
+        const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
+        return info?.type === "File"
+      })
+
+      const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
+        return yield* Effect.tryPromise({
+          try: async () => {
+            const entries = await NFS.readdir(dirPath, { withFileTypes: true })
+            return entries.map(
+              (e): DirEntry => ({
+                name: e.name,
+                type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
+              }),
+            )
+          },
+          catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
+        })
+      })
+
+      const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
+        const text = yield* fs.readFileString(path)
+        return JSON.parse(text)
+      })
+
+      const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
+        const content = JSON.stringify(data, null, 2)
+        yield* fs.writeFileString(path, content)
+        if (mode) yield* fs.chmod(path, mode)
+      })
+
+      const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
+        yield* fs.makeDirectory(path, { recursive: true })
+      })
+
+      const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
+        path: string,
+        content: string | Uint8Array,
+        mode?: number,
+      ) {
+        const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
+
+        yield* write.pipe(
+          Effect.catchIf(
+            (e) => e.reason._tag === "NotFound",
+            () =>
+              Effect.gen(function* () {
+                yield* fs.makeDirectory(dirname(path), { recursive: true })
+                yield* write
+              }),
+          ),
+        )
+        if (mode) yield* fs.chmod(path, mode)
+      })
+
+      const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
+        return yield* Effect.tryPromise({
+          try: () => Glob.scan(pattern, options),
+          catch: (cause) => new FileSystemError({ method: "glob", cause }),
+        })
+      })
+
+      const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
+        const result: string[] = []
+        let current = start
+        while (true) {
+          const search = join(current, target)
+          if (yield* fs.exists(search)) result.push(search)
+          if (stop === current) break
+          const parent = dirname(current)
+          if (parent === current) break
+          current = parent
+        }
+        return result
+      })
+
+      const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
+        const result: string[] = []
+        let current = options.start
+        while (true) {
+          for (const target of options.targets) {
+            const search = join(current, target)
+            if (yield* fs.exists(search)) result.push(search)
+          }
+          if (options.stop === current) break
+          const parent = dirname(current)
+          if (parent === current) break
+          current = parent
+        }
+        return result
+      })
+
+      const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
+        const result: string[] = []
+        let current = start
+        while (true) {
+          const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
+            Effect.catch(() => Effect.succeed([] as string[])),
+          )
+          result.push(...matches)
+          if (stop === current) break
+          const parent = dirname(current)
+          if (parent === current) break
+          current = parent
+        }
+        return result
+      })
+
+      return Service.of({
+        ...fs,
+        existsSafe,
+        readFileStringSafe,
+        isDir,
+        isFile,
+        readDirectoryEntries,
+        readJson,
+        writeJson,
+        ensureDir,
+        writeWithDirs,
+        findUp,
+        up,
+        globUp,
+        glob,
+        globMatch: Glob.match,
+      })
+    }),
+  )
+
+  export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
+
+  // Pure helpers that don't need Effect (path manipulation, sync operations)
+  export function mimeType(p: string): string {
+    return lookup(p) || "application/octet-stream"
+  }
+
+  export function normalizePath(p: string): string {
+    if (process.platform !== "win32") return p
+    const resolved = pathResolve(windowsPath(p))
+    try {
+      return realpathSync.native(resolved)
+    } catch {
+      return resolved
+    }
+  }
+
+  export function normalizePathPattern(p: string): string {
+    if (process.platform !== "win32") return p
+    if (p === "*") return p
+    const match = p.match(/^(.*)[\\/]\*$/)
+    if (!match) return normalizePath(p)
+    const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
+    return join(normalizePath(dir), "*")
+  }
+
+  export function resolve(p: string): string {
+    const resolved = pathResolve(windowsPath(p))
+    try {
+      return normalizePath(realpathSync(resolved))
+    } catch (e: any) {
+      if (e?.code === "ENOENT") return normalizePath(resolved)
+      throw e
+    }
+  }
+
+  export function windowsPath(p: string): string {
+    if (process.platform !== "win32") return p
+    return p
+      .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+      .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+      .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+      .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+  }
+
+  export function overlaps(a: string, b: string) {
+    const relA = relative(a, b)
+    const relB = relative(b, a)
+    return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
+  }
+
+  export function contains(parent: string, child: string) {
+    return !relative(parent, child).startsWith("..")
+  }
+}

+ 12 - 7
packages/core/src/git.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { Context, Effect, Layer } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { AbsolutePath } from "./schema"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { AppProcess } from "./process"
 
 export interface Repo {
@@ -32,6 +32,7 @@ export interface Interface {
   readonly roots: (repo: Repo) => Effect.Effect<string[]>
   readonly origin: (directory: string) => Effect.Effect<string | undefined>
   readonly head: (directory: string) => Effect.Effect<string | undefined>
+  readonly dir: (directory: string) => Effect.Effect<string | undefined>
   readonly branch: (directory: string) => Effect.Effect<string | undefined>
   readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
   readonly clone: (input: {
@@ -51,7 +52,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Gi
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const proc = yield* AppProcess.Service
 
     const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
@@ -101,6 +102,12 @@ export const layer = Layer.effect(
       return result.text.trim() || undefined
     })
 
+    const dir = Effect.fn("Git.dir")(function* (directory: string) {
+      const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
+      if (result.exitCode !== 0) return undefined
+      return AbsolutePath.make(resolvePath(directory, result.text))
+    })
+
     const branch = Effect.fn("Git.branch")(function* (directory: string) {
       const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
       if (result.exitCode !== 0) return undefined
@@ -148,6 +155,7 @@ export const layer = Layer.effect(
       roots,
       origin,
       head,
+      dir,
       branch,
       remoteHead,
       clone,
@@ -159,10 +167,7 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(
-  Layer.provide(AppFileSystem.defaultLayer),
-  Layer.provide(AppProcess.defaultLayer),
-)
+export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
 
 export interface Result {
   readonly exitCode: number
@@ -200,7 +205,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
 function resolvePath(cwd: string, value: string) {
   const trimmed = value.replace(/[\r\n]+$/, "")
   if (!trimmed) return cwd
-  const normalized = AppFileSystem.windowsPath(trimmed)
+  const normalized = FSUtil.windowsPath(trimmed)
   if (path.isAbsolute(normalized)) return path.normalize(normalized)
   return path.resolve(cwd, normalized)
 }

+ 0 - 194
packages/core/src/location-filesystem.ts

@@ -1,194 +0,0 @@
-export * as LocationFileSystem from "./location-filesystem"
-
-import path from "path"
-import { pathToFileURL } from "url"
-import { Context, Effect, Layer, Schema } from "effect"
-import { AppFileSystem } from "./filesystem"
-import { Location } from "./location"
-import { ProjectReference } from "./project-reference"
-import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
-
-export const ReadInput = Schema.Struct({
-  path: RelativePath,
-  reference: Schema.String.pipe(Schema.optional),
-})
-export type ReadInput = typeof ReadInput.Type
-
-export class TextContent extends Schema.Class<TextContent>("LocationFileSystem.TextContent")({
-  type: Schema.Literal("text"),
-  content: Schema.String,
-  mime: Schema.String,
-}) {}
-
-export class BinaryContent extends Schema.Class<BinaryContent>("LocationFileSystem.BinaryContent")({
-  type: Schema.Literal("binary"),
-  content: Schema.String,
-  encoding: Schema.Literal("base64"),
-  mime: Schema.String,
-}) {}
-
-export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
-export type Content = typeof Content.Type
-
-export const ListInput = Schema.Struct({
-  path: RelativePath.pipe(Schema.optional),
-  reference: Schema.String.pipe(Schema.optional),
-})
-export type ListInput = typeof ListInput.Type
-
-export class Entry extends Schema.Class<Entry>("LocationFileSystem.Entry")({
-  path: RelativePath,
-  uri: Schema.String,
-  type: Schema.Literals(["file", "directory"]),
-  mime: Schema.String,
-}) {}
-
-export const FindInput = Schema.Struct({
-  query: Schema.String,
-  type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
-  limit: PositiveInt.pipe(Schema.optional),
-})
-export type FindInput = typeof FindInput.Type
-
-export const GrepInput = Schema.Struct({
-  pattern: Schema.String,
-  include: Schema.String.pipe(Schema.optional),
-  limit: PositiveInt.pipe(Schema.optional),
-})
-export type GrepInput = typeof GrepInput.Type
-
-export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
-  path: RelativePath,
-  lines: Schema.String,
-  line: PositiveInt,
-  offset: NonNegativeInt,
-  submatches: Schema.Array(
-    Schema.Struct({
-      text: Schema.String,
-      start: NonNegativeInt,
-      end: NonNegativeInt,
-    }),
-  ),
-}) {}
-
-export interface Interface {
-  readonly read: (input: ReadInput) => Effect.Effect<Content>
-  readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
-  readonly find: (input: FindInput) => Effect.Effect<Entry[]>
-  readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationFileSystem") {}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
-    const location = yield* Location.Service
-    const references = yield* ProjectReference.Service
-    const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
-    const select = Effect.fnUntraced(function* (reference?: string) {
-      if (!reference) return { directory: location.directory, root }
-      const resolved = yield* references.get(reference)
-      if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
-      if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
-      if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
-      return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
-    })
-    const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
-      if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
-      const selected = yield* select(reference)
-      const absolute = path.resolve(selected.directory, input ?? ".")
-      if (!AppFileSystem.contains(selected.directory, absolute))
-        return yield* Effect.die(new Error("Path escapes the location"))
-      const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
-      if (!AppFileSystem.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
-      return { absolute, real, ...selected }
-    })
-    const entry = Effect.fnUntraced(function* (absolute: string, selected: { directory: string; root: string }) {
-      const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
-      if (!real) return
-      if (!AppFileSystem.contains(selected.root, real)) return
-      const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
-      if (!info) return
-      const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
-      if (!type) return
-      return new Entry({
-        path: RelativePath.make(path.relative(selected.directory, absolute)),
-        uri: pathToFileURL(real).href,
-        type,
-        mime: type === "directory" ? "application/x-directory" : AppFileSystem.mimeType(real),
-      })
-    })
-    const entries = [
-      new Entry({
-        path: RelativePath.make("README.md"),
-        uri: pathToFileURL(path.join(location.directory, "README.md")).href,
-        type: "file",
-        mime: "text/markdown",
-      }),
-      new Entry({
-        path: RelativePath.make("src"),
-        uri: pathToFileURL(path.join(location.directory, "src")).href,
-        type: "directory",
-        mime: "application/x-directory",
-      }),
-    ]
-
-    return Service.of({
-      read: Effect.fn("LocationFileSystem.read")(function* (input) {
-        const file = yield* resolve(input.path, input.reference)
-        const info = yield* fs.stat(file.real).pipe(Effect.orDie)
-        if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
-        const bytes = yield* fs.readFile(file.real).pipe(Effect.orDie)
-        const mime = AppFileSystem.mimeType(file.real)
-        if (!bytes.includes(0)) {
-          const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
-            Effect.option,
-          )
-          if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
-        }
-        return new BinaryContent({
-          type: "binary",
-          content: Buffer.from(bytes).toString("base64"),
-          encoding: "base64",
-          mime,
-        })
-      }),
-      list: Effect.fn("LocationFileSystem.list")(function* (input = {}) {
-        const directory = yield* resolve(input.path, input.reference)
-        const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
-        if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
-        return yield* fs.readDirectoryEntries(directory.real).pipe(
-          Effect.orDie,
-          Effect.flatMap((items) =>
-            Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
-              concurrency: "unbounded",
-            }),
-          ),
-          Effect.map((items) =>
-            items
-              .filter((item): item is Entry => item !== undefined)
-              .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
-          ),
-        )
-      }),
-      find: Effect.fn("LocationFileSystem.find")(function* (input) {
-        return entries.filter((entry) => input.type === undefined || entry.type === input.type).slice(0, input.limit)
-      }),
-      grep: Effect.fn("LocationFileSystem.grep")(function* (input) {
-        return [
-          new GrepMatch({
-            path: RelativePath.make("README.md"),
-            lines: "# opencode",
-            line: 1,
-            offset: 0,
-            submatches: [{ text: input.pattern, start: 0, end: input.pattern.length }],
-          }),
-        ].slice(0, input.limit)
-      }),
-    })
-  }),
-)
-
-export const locationLayer = layer.pipe(Layer.provideMerge(ProjectReference.locationLayer))

+ 6 - 4
packages/core/src/location-layer.ts

@@ -11,13 +11,14 @@ import { EventV2 } from "./event"
 import { Auth } from "./auth"
 import { Npm } from "./npm"
 import { ModelsDev } from "./models-dev"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { Global } from "./global"
 import { Database } from "./database/database"
 import { PermissionV2 } from "./permission"
 import { PermissionSaved } from "./permission/saved"
 import { SessionV2 } from "./session"
-import { LocationFileSystem } from "./location-filesystem"
+import { FileSystem } from "./filesystem"
+import { Watcher } from "./filesystem/watcher"
 import { ProjectReference } from "./project-reference"
 import { RepositoryCache } from "./repository-cache"
 
@@ -34,7 +35,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
       AgentV2.locationLayer,
       PluginBoot.locationLayer,
       PermissionV2.locationLayer,
-      LocationFileSystem.locationLayer,
+      FileSystem.locationLayer,
+      Watcher.locationLayer,
     ).pipe(Layer.provideMerge(location), Layer.fresh)
   },
   idleTimeToLive: "60 minutes",
@@ -44,7 +46,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
     Auth.defaultLayer,
     Npm.defaultLayer,
     ModelsDev.defaultLayer,
-    AppFileSystem.defaultLayer,
+    FSUtil.defaultLayer,
     Global.defaultLayer,
     Database.defaultLayer,
     SessionV2.defaultLayer,

+ 3 - 3
packages/core/src/models-dev.ts

@@ -5,7 +5,7 @@ import { Global } from "./global"
 import { Flag } from "./flag/flag"
 import { Flock } from "./util/flock"
 import { Hash } from "./util/hash"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { InstallationChannel, InstallationVersion } from "./installation/version"
 import { EventV2 } from "./event"
 
@@ -125,7 +125,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const events = yield* EventV2.Service
     const http = HttpClient.filterStatusOk(
       (yield* HttpClient.HttpClient).pipe(
@@ -227,7 +227,7 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(
   Layer.provide(FetchHttpClient.layer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
 )
 

+ 3 - 3
packages/core/src/npm.ts

@@ -4,7 +4,7 @@ import path from "path"
 import npa from "npm-package-arg"
 import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
 import { NodeFileSystem } from "@effect/platform-node"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { Global } from "./global"
 import { EffectFlock } from "./util/effect-flock"
 import { makeRuntime } from "./effect/runtime"
@@ -70,7 +70,7 @@ interface ArboristTree {
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const afs = yield* AppFileSystem.Service
+    const afs = yield* FSUtil.Service
     const global = yield* Global.Service
     const fs = yield* FileSystem.FileSystem
     const flock = yield* EffectFlock.Service
@@ -246,7 +246,7 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(
   Layer.provide(EffectFlock.layer),
-  Layer.provide(AppFileSystem.layer),
+  Layer.provide(FSUtil.layer),
   Layer.provide(Global.layer),
   Layer.provide(NodeFileSystem.layer),
 )

+ 5 - 5
packages/core/src/project-reference.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { Context, Effect, Layer } from "effect"
 import { Config } from "./config"
 import { ConfigReference } from "./config/reference"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { Flag } from "./flag/flag"
 import { Global } from "./global"
 import { Location } from "./location"
@@ -65,7 +65,7 @@ export const layer = Layer.effect(
     if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
 
     const config = yield* Config.Service
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const global = yield* Global.Service
     const location = yield* Location.Service
     const cache = yield* RepositoryCache.Service
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
         if (!target) return { name, kind: "reference", reference, path: reference.path }
 
         const resolved = path.resolve(reference.path, target)
-        if (!AppFileSystem.contains(reference.path, resolved))
+        if (!FSUtil.contains(reference.path, resolved))
           return { name, kind: "invalid", target, message: "Reference target escapes its root" }
         if (!(yield* fs.existsSafe(resolved)))
           return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
@@ -228,9 +228,9 @@ function uniqueGitReferences(references: Resolved[]) {
 
 function normalizePath(target?: string) {
   if (!target) return
-  return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
+  return process.platform === "win32" ? FSUtil.normalizePath(target) : target
 }
 
 function contains(parent: string, child: string) {
-  return AppFileSystem.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
+  return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
 }

+ 3 - 3
packages/core/src/project.ts

@@ -4,7 +4,7 @@ export * as Project from "./project"
 import { Context, Effect, Layer, Schema } from "effect"
 import path from "path"
 import { AbsolutePath, withStatics } from "./schema"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { Git } from "./git"
 import { Hash } from "./util/hash"
 
@@ -55,7 +55,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const git = yield* Git.Service
 
     const cached = Effect.fnUntraced(function* (dir: string) {
@@ -126,4 +126,4 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))

+ 118 - 115
packages/core/src/repository-cache.ts

@@ -1,6 +1,6 @@
 import path from "path"
 import { Context, Effect, Layer, Schema } from "effect"
-import { AppFileSystem } from "./filesystem"
+import { FSUtil } from "./fs-util"
 import { Git } from "./git"
 import { Global } from "./global"
 import { Repository } from "./repository"
@@ -119,139 +119,142 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
   })
 })
 
-export const layer: Layer.Layer<
-  Service,
-  never,
-  AppFileSystem.Service | Git.Service | EffectFlock.Service | Global.Service
-> = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
-    const git = yield* Git.Service
-    const flock = yield* EffectFlock.Service
-    const global = yield* Global.Service
-
-    return Service.of({
-      ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
-        if (input.branch) yield* validateBranch(input.branch)
-
-        const repository = input.reference.label
-        const localPath = Repository.cachePath(global.repos, input.reference)
-        const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
-
-        return yield* flock
-          .withLock(
-            Effect.gen(function* () {
-              yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
-
-              const exists = yield* fs.existsSafe(localPath)
-              const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
-              const origin = hasGitDir ? yield* git.origin(localPath) : undefined
-              const originReference = origin ? Repository.parse(origin) : undefined
-              const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
-              if (exists && !reuse) {
-                yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
-              }
-
-              const currentBranch = reuse ? yield* git.branch(localPath) : undefined
-              const status = statusForRepository({
-                reuse,
-                refresh: input.refresh,
-                branchMatches: input.branch ? currentBranch === input.branch : undefined,
-              })
-
-              if (status === "cloned") {
-                const result = yield* git
-                  .clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
-                  .pipe(Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })))
-                if (result.exitCode !== 0) {
-                  return yield* new CloneFailedError({
-                    repository,
-                    message: resultMessage(result, `Failed to clone ${repository}`),
-                  })
+export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFlock.Service | Global.Service> =
+  Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* FSUtil.Service
+      const git = yield* Git.Service
+      const flock = yield* EffectFlock.Service
+      const global = yield* Global.Service
+
+      return Service.of({
+        ensure: Effect.fn("RepositoryCache.ensure")(function* (input) {
+          if (input.branch) yield* validateBranch(input.branch)
+
+          const repository = input.reference.label
+          const localPath = Repository.cachePath(global.repos, input.reference)
+          const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
+
+          return yield* flock
+            .withLock(
+              Effect.gen(function* () {
+                yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
+
+                const exists = yield* fs.existsSafe(localPath)
+                const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git"))
+                const origin = hasGitDir ? yield* git.origin(localPath) : undefined
+                const originReference = origin ? Repository.parse(origin) : undefined
+                const reuse = hasGitDir && Boolean(originReference && Repository.same(originReference, cloneTarget))
+                if (exists && !reuse) {
+                  yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
                 }
-              }
-
-              if (status === "refreshed") {
-                const fetch = yield* git
-                  .fetch(localPath)
-                  .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })))
-                if (fetch.exitCode !== 0) {
-                  return yield* new FetchFailedError({
-                    repository,
-                    message: resultMessage(fetch, `Failed to refresh ${repository}`),
-                  })
+
+                const currentBranch = reuse ? yield* git.branch(localPath) : undefined
+                const status = statusForRepository({
+                  reuse,
+                  refresh: input.refresh,
+                  branchMatches: input.branch ? currentBranch === input.branch : undefined,
+                })
+
+                if (status === "cloned") {
+                  const result = yield* git
+                    .clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
+                    .pipe(
+                      Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
+                    )
+                  if (result.exitCode !== 0) {
+                    return yield* new CloneFailedError({
+                      repository,
+                      message: resultMessage(result, `Failed to clone ${repository}`),
+                    })
+                  }
                 }
 
-                if (input.branch) {
-                  const requestedBranch = input.branch
-                  const fetchBranch = yield* git
-                    .fetchBranch(localPath, requestedBranch)
+                if (status === "refreshed") {
+                  const fetch = yield* git
+                    .fetch(localPath)
                     .pipe(
                       Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
                     )
-                  if (fetchBranch.exitCode !== 0) {
+                  if (fetch.exitCode !== 0) {
                     return yield* new FetchFailedError({
                       repository,
-                      message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
+                      message: resultMessage(fetch, `Failed to refresh ${repository}`),
                     })
                   }
 
-                  const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
-                    Effect.mapError(
-                      (error) =>
-                        new CheckoutFailedError({
-                          repository,
-                          branch: requestedBranch,
-                          message: errorMessage(error),
-                        }),
-                    ),
-                  )
-                  if (checkout.exitCode !== 0) {
-                    return yield* new CheckoutFailedError({
+                  if (input.branch) {
+                    const requestedBranch = input.branch
+                    const fetchBranch = yield* git
+                      .fetchBranch(localPath, requestedBranch)
+                      .pipe(
+                        Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
+                      )
+                    if (fetchBranch.exitCode !== 0) {
+                      return yield* new FetchFailedError({
+                        repository,
+                        message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`),
+                      })
+                    }
+
+                    const checkout = yield* git.checkout(localPath, requestedBranch).pipe(
+                      Effect.mapError(
+                        (error) =>
+                          new CheckoutFailedError({
+                            repository,
+                            branch: requestedBranch,
+                            message: errorMessage(error),
+                          }),
+                      ),
+                    )
+                    if (checkout.exitCode !== 0) {
+                      return yield* new CheckoutFailedError({
+                        repository,
+                        branch: requestedBranch,
+                        message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
+                      })
+                    }
+                  }
+
+                  const reset = yield* git
+                    .reset(localPath, yield* resetTarget(git, localPath, input.branch))
+                    .pipe(
+                      Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })),
+                    )
+                  if (reset.exitCode !== 0) {
+                    return yield* new ResetFailedError({
                       repository,
-                      branch: requestedBranch,
-                      message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
+                      message: resultMessage(reset, `Failed to reset ${repository}`),
                     })
                   }
                 }
 
-                const reset = yield* git
-                  .reset(localPath, yield* resetTarget(git, localPath, input.branch))
-                  .pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: errorMessage(error) })))
-                if (reset.exitCode !== 0) {
-                  return yield* new ResetFailedError({
-                    repository,
-                    message: resultMessage(reset, `Failed to reset ${repository}`),
-                  })
-                }
-              }
-
-              return {
-                repository,
-                host: input.reference.host,
-                remote: input.reference.remote,
-                localPath,
-                status,
-                head: yield* git.head(localPath),
-                branch: yield* git.branch(localPath),
-              } satisfies Result
-            }),
-            `repository-cache:${localPath}`,
-          )
-          .pipe(
-            Effect.mapError((error) =>
-              isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
-            ),
-          )
-      }),
-    })
-  }),
-)
+                return {
+                  repository,
+                  host: input.reference.host,
+                  remote: input.reference.remote,
+                  localPath,
+                  status,
+                  head: yield* git.head(localPath),
+                  branch: yield* git.branch(localPath),
+                } satisfies Result
+              }),
+              `repository-cache:${localPath}`,
+            )
+            .pipe(
+              Effect.mapError((error) =>
+                isError(error) ? error : new LockFailedError({ localPath, message: errorMessage(error) }),
+              ),
+            )
+        }),
+      })
+    }),
+  )
 
 export const defaultLayer: Layer.Layer<Service> = layer.pipe(
   Layer.provide(EffectFlock.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Git.defaultLayer),
   Layer.provide(Global.defaultLayer),
 )

+ 4 - 4
packages/core/src/util/effect-flock.ts

@@ -4,7 +4,7 @@ import { randomUUID } from "crypto"
 import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect"
 import type { FileSystem, Scope } from "effect"
 import type { PlatformError } from "effect/PlatformError"
-import { AppFileSystem } from "../filesystem"
+import { FSUtil } from "../fs-util"
 import { Global } from "../global"
 import { Hash } from "./hash"
 
@@ -93,11 +93,11 @@ export namespace EffectFlock {
 
   const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown"
 
-  export const layer: Layer.Layer<Service, never, Global.Service | AppFileSystem.Service> = Layer.effect(
+  export const layer: Layer.Layer<Service, never, Global.Service | FSUtil.Service> = Layer.effect(
     Service,
     Effect.gen(function* () {
       const global = yield* Global.Service
-      const fs = yield* AppFileSystem.Service
+      const fs = yield* FSUtil.Service
       const lockRoot = path.join(global.state, "locks")
       const hostname = os.hostname()
       const ensuredDirs = new Set<string>()
@@ -279,5 +279,5 @@ export namespace EffectFlock {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer))
+  export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
 }

+ 1 - 1
packages/opencode/src/util/which.ts → packages/core/src/util/which.ts

@@ -1,6 +1,6 @@
 import whichPkg from "which"
 import path from "path"
-import { Global } from "@opencode-ai/core/global"
+import { Global } from "../global"
 
 export function which(cmd: string, env?: NodeJS.ProcessEnv) {
   const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""

+ 2 - 2
packages/core/test/account.test.ts

@@ -5,7 +5,7 @@ import { Effect, Fiber, Layer, Option, Stream } from "effect"
 import { Auth } from "@opencode-ai/core/auth"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { EventV2 } from "@opencode-ai/core/event"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { PluginV2 } from "@opencode-ai/core/plugin"
 import { AccountPlugin } from "@opencode-ai/core/plugin/account"
@@ -57,7 +57,7 @@ function context(
 
 function testLayer(dir: string) {
   return Auth.layer.pipe(
-    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(FSUtil.defaultLayer),
     Layer.provideMerge(EventV2.defaultLayer),
     Layer.provide(
       Global.layerWith({

+ 2 - 2
packages/core/test/config/config.test.ts

@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigProvider } from "@opencode-ai/core/config/provider"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { Location } from "@opencode-ai/core/location"
 import { Policy } from "@opencode-ai/core/policy"
@@ -23,7 +23,7 @@ function testLayer(
   vcs?: Project.Vcs,
 ) {
   return Config.locationLayer.pipe(
-    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(FSUtil.defaultLayer),
     Layer.provide(Global.layerWith({ config: globalDirectory })),
     Layer.provide(
       Layer.succeed(

+ 34 - 34
packages/core/test/filesystem/filesystem.test.ts

@@ -1,19 +1,19 @@
 import { describe, test, expect } from "bun:test"
 import { Effect, Layer, FileSystem } from "effect"
 import { NodeFileSystem } from "@effect/platform-node"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { testEffect } from "../lib/effect"
 import path from "path"
 
-const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
+const live = FSUtil.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
 const { effect: it } = testEffect(live)
 
-describe("AppFileSystem", () => {
+describe("FSUtil", () => {
   describe("isDir", () => {
     it(
       "returns true for directories",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         expect(yield* fs.isDir(tmp)).toBe(true)
@@ -23,7 +23,7 @@ describe("AppFileSystem", () => {
     it(
       "returns false for files",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "test.txt")
@@ -35,7 +35,7 @@ describe("AppFileSystem", () => {
     it(
       "returns false for non-existent paths",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
       }),
     )
@@ -45,7 +45,7 @@ describe("AppFileSystem", () => {
     it(
       "returns true for files",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "test.txt")
@@ -57,7 +57,7 @@ describe("AppFileSystem", () => {
     it(
       "returns false for directories",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         expect(yield* fs.isFile(tmp)).toBe(false)
@@ -69,7 +69,7 @@ describe("AppFileSystem", () => {
     it(
       "returns file contents when file exists",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "exists.txt")
@@ -83,7 +83,7 @@ describe("AppFileSystem", () => {
     it(
       "returns undefined for missing file (NotFound)",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
 
@@ -97,7 +97,7 @@ describe("AppFileSystem", () => {
     it(
       "round-trips JSON data",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "data.json")
@@ -115,7 +115,7 @@ describe("AppFileSystem", () => {
     it(
       "creates nested directories",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const nested = path.join(tmp, "a", "b", "c")
@@ -130,7 +130,7 @@ describe("AppFileSystem", () => {
     it(
       "is idempotent",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const dir = path.join(tmp, "existing")
@@ -148,7 +148,7 @@ describe("AppFileSystem", () => {
     it(
       "creates parent directories if missing",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "deep", "nested", "file.txt")
@@ -162,7 +162,7 @@ describe("AppFileSystem", () => {
     it(
       "writes directly when parent exists",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "direct.txt")
@@ -176,7 +176,7 @@ describe("AppFileSystem", () => {
     it(
       "writes Uint8Array content",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "binary.bin")
@@ -194,7 +194,7 @@ describe("AppFileSystem", () => {
     it(
       "finds target in start directory",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
@@ -207,7 +207,7 @@ describe("AppFileSystem", () => {
     it(
       "finds target in parent directories",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
@@ -222,7 +222,7 @@ describe("AppFileSystem", () => {
     it(
       "returns empty array when not found",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const result = yield* fs.findUp("nonexistent", tmp, tmp)
@@ -235,7 +235,7 @@ describe("AppFileSystem", () => {
     it(
       "finds multiple targets walking up",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
@@ -257,7 +257,7 @@ describe("AppFileSystem", () => {
     it(
       "finds files matching pattern",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
@@ -272,7 +272,7 @@ describe("AppFileSystem", () => {
     it(
       "supports absolute paths",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
@@ -287,7 +287,7 @@ describe("AppFileSystem", () => {
     it(
       "matches patterns",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
         expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
         expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
@@ -299,7 +299,7 @@ describe("AppFileSystem", () => {
     it(
       "finds files walking up directories",
       Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
+        const fs = yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
@@ -318,7 +318,7 @@ describe("AppFileSystem", () => {
     it(
       "exists works",
       Effect.gen(function* () {
-        yield* AppFileSystem.Service
+        yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "exists.txt")
@@ -332,7 +332,7 @@ describe("AppFileSystem", () => {
     it(
       "remove works",
       Effect.gen(function* () {
-        yield* AppFileSystem.Service
+        yield* FSUtil.Service
         const filesys = yield* FileSystem.FileSystem
         const tmp = yield* filesys.makeTempDirectoryScoped()
         const file = path.join(tmp, "delete-me.txt")
@@ -347,20 +347,20 @@ describe("AppFileSystem", () => {
 
   describe("pure helpers", () => {
     test("mimeType returns correct types", () => {
-      expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
-      expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
-      expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
+      expect(FSUtil.mimeType("file.json")).toBe("application/json")
+      expect(FSUtil.mimeType("image.png")).toBe("image/png")
+      expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
     })
 
     test("contains checks path containment", () => {
-      expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
-      expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
+      expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
+      expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
     })
 
     test("overlaps detects overlapping paths", () => {
-      expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
-      expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
-      expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
+      expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
+      expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
+      expect(FSUtil.overlaps("/a", "/b")).toBe(false)
     })
   })
 })

+ 10 - 0
packages/core/test/filesystem/ignore.test.ts

@@ -0,0 +1,10 @@
+import { expect, test } from "bun:test"
+import { Ignore } from "@opencode-ai/core/filesystem/ignore"
+
+test("match nested and non-nested", () => {
+  expect(Ignore.match("node_modules/index.js")).toBe(true)
+  expect(Ignore.match("node_modules")).toBe(true)
+  expect(Ignore.match("node_modules/")).toBe(true)
+  expect(Ignore.match("node_modules/bar")).toBe(true)
+  expect(Ignore.match("node_modules/bar/")).toBe(true)
+})

+ 1 - 1
packages/opencode/test/file/ripgrep.test.ts → packages/core/test/filesystem/ripgrep.test.ts

@@ -4,7 +4,7 @@ import * as Stream from "effect/Stream"
 import fs from "fs/promises"
 import os from "os"
 import path from "path"
-import { Ripgrep } from "../../src/file/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { testEffect } from "../lib/effect"
 
 const it = testEffect(Ripgrep.defaultLayer)

+ 272 - 0
packages/core/test/filesystem/watcher.test.ts

@@ -0,0 +1,272 @@
+import { $ } from "bun"
+import { describe, expect } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
+import { Config } from "@opencode-ai/core/config"
+import { EventV2 } from "@opencode-ai/core/event"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
+import { Git } from "@opencode-ai/core/git"
+import { Location } from "@opencode-ai/core/location"
+import { AbsolutePath } from "@opencode-ai/core/schema"
+import { location } from "../fixture/location"
+import { tmpdir } from "../fixture/tmpdir"
+import { testEffect } from "../lib/effect"
+
+const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
+
+type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
+
+const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
+
+const configLayer = Layer.succeed(
+  Config.Service,
+  Config.Service.of({
+    directories: () => Effect.succeed([]),
+    get: () => Effect.succeed([]),
+  }),
+)
+
+const flagsLayer = ConfigProvider.layer(
+  ConfigProvider.fromUnknown({
+    OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
+    OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
+  }),
+)
+
+function provide(directory: string, vcs?: Location.Interface["vcs"]) {
+  const locationLayer = Layer.succeed(
+    Location.Service,
+    Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
+  )
+  return Effect.provide(
+    Watcher.layer.pipe(
+      Layer.provide(configLayer),
+      Layer.provide(Git.defaultLayer),
+      Layer.provide(locationLayer),
+      Layer.provide(flagsLayer),
+    ),
+  )
+}
+
+function withTmp<A, E, R>(
+  f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
+  options?: { git?: boolean; init?: (directory: string) => Promise<void> },
+) {
+  return Effect.acquireRelease(
+    Effect.promise(async () => {
+      const tmp = await tmpdir()
+      if (!options?.git) return { tmp, vcs: undefined }
+      await $`git init`.cwd(tmp.path).quiet()
+      await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
+      await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
+      await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
+      await $`git config user.name Test`.cwd(tmp.path).quiet()
+      await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
+      await options.init?.(tmp.path)
+      return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
+    }),
+    ({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+  ).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
+}
+
+function wait(check: (event: WatcherEvent) => boolean) {
+  return Effect.gen(function* () {
+    const events = yield* EventV2.Service
+    const deferred = yield* Deferred.make<WatcherEvent>()
+    const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
+      Stream.runForEach((event) => {
+        if (!check(event.data)) return Effect.void
+        return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
+      }),
+      Effect.forkScoped,
+    )
+    yield* Effect.yieldNow
+    return { deferred, fiber }
+  })
+}
+
+function maybeNextUpdate<E>(
+  check: (event: WatcherEvent) => boolean,
+  trigger: Effect.Effect<void, E>,
+  timeout: Duration.Input = "5 seconds",
+) {
+  return Effect.acquireUseRelease(
+    wait(check),
+    ({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
+    ({ fiber }) => Fiber.interrupt(fiber),
+  )
+}
+
+function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
+  return Effect.gen(function* () {
+    const result = yield* maybeNextUpdate(check, trigger)
+    if (Option.isSome(result)) return result.value
+    return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
+  })
+}
+
+function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
+  return Effect.gen(function* () {
+    while (true) {
+      const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
+      if (Option.isSome(result)) return result.value
+    }
+  }).pipe(
+    Effect.timeoutOrElse({
+      duration: "5 seconds",
+      orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
+    }),
+  )
+}
+
+function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
+  return Effect.acquireUseRelease(
+    wait(check),
+    ({ deferred }) =>
+      trigger.pipe(
+        Effect.andThen(Deferred.await(deferred)),
+        Effect.timeoutOption(`${timeout} millis`),
+        Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
+      ),
+    ({ fiber }) => Fiber.interrupt(fiber),
+  )
+}
+
+function ready(directory: string) {
+  const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
+  return Effect.gen(function* () {
+    const fs = yield* FSUtil.Service
+    yield* eventuallyUpdate(
+      (event) => event.file === file,
+      () => fs.writeFileString(file, `ready-${Math.random()}`),
+    ).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
+  })
+}
+
+describeWatcher("Watcher", () => {
+  it.live("publishes root create, update, and delete events", () =>
+    withTmp(
+      (directory) =>
+        Effect.gen(function* () {
+          const fs = yield* FSUtil.Service
+          const file = path.join(directory, "watch.txt")
+          yield* ready(directory)
+          for (const item of [
+            { event: "add" as const, trigger: fs.writeFileString(file, "a") },
+            { event: "change" as const, trigger: fs.writeFileString(file, "b") },
+            { event: "unlink" as const, trigger: fs.remove(file) },
+          ]) {
+            expect(
+              yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
+            ).toEqual({
+              file,
+              event: item.event,
+            })
+          }
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live("watches non-git roots", () =>
+    withTmp((directory) =>
+      Effect.gen(function* () {
+        const fs = yield* FSUtil.Service
+        const file = path.join(directory, "plain.txt")
+        yield* ready(directory)
+        expect(yield* nextUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))).toEqual({
+          file,
+          event: "add",
+        })
+      }),
+    ),
+  )
+
+  it.live("cleanup stops publishing events", () =>
+    Effect.gen(function* () {
+      const events = yield* EventV2.Service
+      const fs = yield* FSUtil.Service
+      const tmp = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+      )
+      yield* ready(tmp.path).pipe(provide(tmp.path), Effect.scoped)
+      const file = path.join(tmp.path, "after-dispose.txt")
+      yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
+        Effect.provideService(EventV2.Service, events),
+      )
+    }).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
+  )
+
+  it.live("ignores .git/index changes", () =>
+    withTmp(
+      (directory) =>
+        Effect.gen(function* () {
+          const fs = yield* FSUtil.Service
+          const index = path.join(directory, ".git", "index")
+          yield* ready(directory)
+          yield* noUpdate(
+            (event) => event.file === index,
+            fs
+              .writeFileString(path.join(directory, "tracked.txt"), "a")
+              .pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
+          )
+        }),
+      { git: true },
+    ),
+  )
+
+  it.live("publishes .git/HEAD events", () =>
+    withTmp(
+      (directory) =>
+        Effect.gen(function* () {
+          const fs = yield* FSUtil.Service
+          const head = path.join(directory, ".git", "HEAD")
+          const branch = `watch-${Math.random().toString(36).slice(2)}`
+          yield* ready(directory)
+          yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
+          expect(
+            yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
+          ).toEqual({
+            file: head,
+            event: "change",
+          })
+        }),
+      { git: true },
+    ),
+  )
+
+  const describeSymlink = process.platform !== "win32" ? describe : describe.skip
+  describeSymlink("symlinked .git", () => {
+    it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
+      withTmp(
+        (directory) =>
+          Effect.gen(function* () {
+            const afs = yield* FSUtil.Service
+            const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
+            yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
+            yield* ready(directory)
+            const head = path.join(directory, ".git", "HEAD")
+            const branch = `watch-${Math.random().toString(36).slice(2)}`
+            yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
+            expect(
+              yield* nextUpdate(
+                (event) => event.file === path.join(actual, "HEAD"),
+                afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
+              ),
+            ).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
+          }),
+        {
+          git: true,
+          init: async (directory) => {
+            const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
+            await fs.rename(path.join(directory, ".git"), actual)
+            await fs.symlink(actual, path.join(directory, ".git"))
+          },
+        },
+      ),
+    )
+  })
+})

+ 2 - 2
packages/core/test/fixture/effect-flock-worker.ts

@@ -1,7 +1,7 @@
 import fs from "fs/promises"
 import os from "os"
 import { Effect, Layer } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { Global } from "@opencode-ai/core/global"
 
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
   log: os.tmpdir(),
 })
 
-const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
 
 async function job() {
   if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))

+ 51 - 13
packages/core/test/location-filesystem.test.ts

@@ -3,9 +3,10 @@ import path from "path"
 import { fileURLToPath } from "url"
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Layer } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Location } from "@opencode-ai/core/location"
-import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { ProjectReference } from "@opencode-ai/core/project-reference"
 import { Repository } from "@opencode-ai/core/repository"
 import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
@@ -23,10 +24,11 @@ const inertReferences = ProjectReference.Service.of({
 
 function provide(directory: string, references = inertReferences) {
   return Effect.provide(
-    LocationFileSystem.layer.pipe(
+    FileSystem.layer.pipe(
       Layer.provide(
         Layer.mergeAll(
-          AppFileSystem.defaultLayer,
+          FSUtil.defaultLayer,
+          Ripgrep.defaultLayer,
           Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
           Layer.succeed(ProjectReference.Service, references),
         ),
@@ -42,13 +44,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
   ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
 }
 
-describe("LocationFileSystem", () => {
+describe("FileSystem", () => {
   it.live("reads text and binary files", () =>
     withTmp((directory) =>
       Effect.gen(function* () {
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "hello.txt"), "hello"))
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2])))
-        const service = yield* LocationFileSystem.Service
+        const service = yield* FileSystem.Service
 
         expect(yield* service.read({ path: RelativePath.make("hello.txt") })).toEqual({
           type: "text",
@@ -70,7 +72,7 @@ describe("LocationFileSystem", () => {
       Effect.gen(function* () {
         yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
         yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test"))
-        const service = yield* LocationFileSystem.Service
+        const service = yield* FileSystem.Service
 
         const entries = yield* service.list()
         expect(entries.map(({ uri: _uri, ...entry }) => entry)).toEqual([
@@ -99,7 +101,7 @@ describe("LocationFileSystem", () => {
   it.live("rejects paths outside the location", () =>
     withTmp((directory) =>
       Effect.gen(function* () {
-        const service = yield* LocationFileSystem.Service
+        const service = yield* FileSystem.Service
         expect(
           Exit.isFailure(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)),
         ).toBe(true)
@@ -107,6 +109,42 @@ describe("LocationFileSystem", () => {
     ),
   )
 
+  it.live("finds files and directories", () =>
+    withTmp((directory) =>
+      Effect.gen(function* () {
+        yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
+        yield* Effect.promise(() => fs.writeFile(path.join(directory, "src", "index.ts"), "const needle = true\n"))
+        const service = yield* FileSystem.Service
+
+        expect((yield* service.find({ query: "index", type: "file" })).map((item) => item.path)).toEqual([
+          RelativePath.make(path.join("src", "index.ts")),
+        ])
+        expect((yield* service.find({ query: "src", type: "directory" })).map((item) => item.path)).toEqual([
+          RelativePath.make("src"),
+        ])
+      }).pipe(provide(directory)),
+    ),
+  )
+
+  it.live("greps file contents", () =>
+    withTmp((directory) =>
+      Effect.gen(function* () {
+        yield* Effect.promise(() => fs.writeFile(path.join(directory, "index.ts"), "const needle = true\n"))
+        const service = yield* FileSystem.Service
+
+        expect(yield* service.grep({ pattern: "needle" })).toEqual([
+          {
+            path: RelativePath.make("index.ts"),
+            lines: "const needle = true\n",
+            line: 1,
+            offset: 0,
+            submatches: [{ text: "needle", start: 6, end: 12 }],
+          },
+        ])
+      }).pipe(provide(directory)),
+    ),
+  )
+
   it.live("reads and lists paths relative to a local project reference", () =>
     withTmp((directory) => {
       const docs = path.join(directory, "docs")
@@ -115,7 +153,7 @@ describe("LocationFileSystem", () => {
           await fs.mkdir(docs)
           await fs.writeFile(path.join(docs, "README.md"), "docs")
         })
-        const service = yield* LocationFileSystem.Service
+        const service = yield* FileSystem.Service
 
         expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
           type: "text",
@@ -136,7 +174,7 @@ describe("LocationFileSystem", () => {
           await fs.writeFile(path.join(docs, "README.md"), "docs")
         })
         expect(
-          yield* (yield* LocationFileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
+          yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
         ).toMatchObject({ content: "docs" })
         expect(ensured).toEqual([docs])
       }).pipe(
@@ -164,7 +202,7 @@ describe("LocationFileSystem", () => {
       const docs = path.join(directory, "docs")
       return Effect.gen(function* () {
         yield* Effect.promise(() => fs.mkdir(docs))
-        const service = yield* LocationFileSystem.Service
+        const service = yield* FileSystem.Service
         expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
         expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
         expect(
@@ -188,7 +226,7 @@ describe("LocationFileSystem", () => {
     withTmp((directory) =>
       Effect.gen(function* () {
         expect(
-          Exit.isFailure(yield* (yield* LocationFileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit)),
+          Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit)),
         ).toBe(true)
       }).pipe(provide(directory)),
     ),
@@ -207,7 +245,7 @@ describe("LocationFileSystem", () => {
         })
         expect(
           Exit.isFailure(
-            yield* (yield* LocationFileSystem.Service)
+            yield* (yield* FileSystem.Service)
               .read({ reference: "docs", path: RelativePath.make("link.txt") })
               .pipe(Effect.exit),
           ),

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

@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
-import { AppFileSystem } from "../src/filesystem"
+import { FSUtil } from "../src/fs-util"
 import { Auth } from "../src/auth"
 import { EventV2 } from "../src/event"
 import { Global } from "../src/global"
@@ -27,7 +27,7 @@ const it = testEffect(
         Auth.defaultLayer,
         Npm.defaultLayer,
         ModelsDev.defaultLayer,
-        AppFileSystem.defaultLayer,
+        FSUtil.defaultLayer,
         Global.defaultLayer,
       ),
     ),

+ 2 - 2
packages/core/test/models.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
 import { Effect, Layer, Ref } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Global } from "@opencode-ai/core/global"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -92,7 +92,7 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
   // every test would reuse the cachedInvalidateWithTTL state from the first run.
   Layer.fresh(ModelsDev.layer).pipe(
     Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
-    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(FSUtil.defaultLayer),
     Layer.provide(EventV2.defaultLayer),
   )
 

+ 2 - 2
packages/core/test/npm.test.ts

@@ -3,7 +3,7 @@ import path from "path"
 import { describe, expect, test } from "bun:test"
 import { NodeFileSystem } from "@effect/platform-node"
 import { Effect, Layer, Option } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { Npm } from "@opencode-ai/core/npm"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
@@ -23,7 +23,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
 const npmLayer = (cache: string) =>
   Npm.layer.pipe(
     Layer.provide(EffectFlock.layer),
-    Layer.provide(AppFileSystem.layer),
+    Layer.provide(FSUtil.layer),
     Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
     Layer.provide(NodeFileSystem.layer),
   )

+ 2 - 2
packages/core/test/project-reference.test.ts

@@ -4,7 +4,7 @@ import path from "path"
 import { Deferred, Effect, Layer, Schema } from "effect"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigReference } from "@opencode-ai/core/config/reference"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Global } from "@opencode-ai/core/global"
 import { Location } from "@opencode-ai/core/location"
@@ -243,7 +243,7 @@ function testLayer(input: {
   return ProjectReference.layer.pipe(
     Layer.provide(
       Layer.mergeAll(
-        AppFileSystem.defaultLayer,
+        FSUtil.defaultLayer,
         Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
         Layer.succeed(
           Location.Service,

+ 2 - 2
packages/core/test/repository-cache.test.ts

@@ -3,7 +3,7 @@ import fs from "fs/promises"
 import path from "path"
 import { pathToFileURL } from "url"
 import { Effect, Layer } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Git } from "@opencode-ai/core/git"
 import { Global } from "@opencode-ai/core/global"
 import { Repository } from "@opencode-ai/core/repository"
@@ -91,7 +91,7 @@ describe("RepositoryCache", () => {
 function cacheLayer(root: string) {
   const dependencies = Layer.mergeAll(
     Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
-    AppFileSystem.defaultLayer,
+    FSUtil.defaultLayer,
   )
   return RepositoryCache.layer.pipe(
     Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),

+ 2 - 2
packages/core/test/util/effect-flock.test.ts

@@ -5,7 +5,7 @@ import path from "path"
 import os from "os"
 import { Cause, Effect, Exit, Layer } from "effect"
 import { testEffect } from "../lib/effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { Global } from "@opencode-ai/core/global"
 import { Hash } from "@opencode-ai/core/util/hash"
@@ -103,7 +103,7 @@ const testGlobal = Global.layerWith({
   log: os.tmpdir(),
 })
 
-const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
+const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
 
 // ---------------------------------------------------------------------------
 // Tests

+ 2 - 2
packages/opencode/test/util/which.test.ts → packages/core/test/util/which.test.ts

@@ -1,8 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
-import { which } from "../../src/util/which"
-import { tmpdir } from "../fixture/fixture"
+import { which } from "@opencode-ai/core/util/which"
+import { tmpdir } from "../fixture/tmpdir"
 
 async function cmd(dir: string, name: string, exec = true) {
   const ext = process.platform === "win32" ? ".cmd" : ""

+ 3 - 3
packages/opencode/BUN_SHELL_MIGRATION_PLAN.md

@@ -90,9 +90,9 @@ Within each file, migrate git paths first where applicable.
 
 Migrate git-centric call sites to `Process.git*` helpers:
 
-- `src/file/index.ts`
+- `../core/src/filesystem.ts`
 - `src/project/vcs.ts`
-- `src/file/watcher.ts`
+- `../core/src/filesystem/watcher.ts`
 - `src/storage/storage.ts`
 - `src/cli/cmd/pr.ts`
 
@@ -102,7 +102,7 @@ Migrate residual non-git usages:
 
 - `src/cli/cmd/tui/util/clipboard.ts`
 - `src/util/archive.ts`
-- `src/file/ripgrep.ts`
+- `../core/src/filesystem/ripgrep.ts`
 - `src/tool/bash.ts`
 - `src/cli/cmd/uninstall.ts`
 

+ 0 - 11
packages/opencode/package.json

@@ -41,14 +41,6 @@
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/http-recorder": "workspace:*",
     "@opencode-ai/script": "workspace:*",
-    "@parcel/watcher-darwin-arm64": "2.5.1",
-    "@parcel/watcher-darwin-x64": "2.5.1",
-    "@parcel/watcher-linux-arm64-glibc": "2.5.1",
-    "@parcel/watcher-linux-arm64-musl": "2.5.1",
-    "@parcel/watcher-linux-x64-glibc": "2.5.1",
-    "@parcel/watcher-linux-x64-musl": "2.5.1",
-    "@parcel/watcher-win32-arm64": "2.5.1",
-    "@parcel/watcher-win32-x64": "2.5.1",
     "@standard-schema/spec": "1.0.0",
     "@tsconfig/bun": "catalog:",
     "@types/babel__core": "7.20.5",
@@ -58,7 +50,6 @@
     "@types/npm-package-arg": "6.1.4",
     "@types/semver": "^7.5.8",
     "@types/turndown": "5.0.5",
-    "@types/which": "3.0.4",
     "@types/yargs": "17.0.33",
     "@typescript/native-preview": "catalog:",
     "drizzle-orm": "catalog:",
@@ -139,7 +130,6 @@
     "google-auth-library": "10.5.0",
     "gray-matter": "4.0.3",
     "htmlparser2": "8.0.2",
-    "ignore": "7.0.5",
     "immer": "11.1.4",
     "jsonc-parser": "3.3.1",
     "mime-types": "3.0.2",
@@ -161,7 +151,6 @@
     "venice-ai-sdk-provider": "2.0.2",
     "vscode-jsonrpc": "8.2.1",
     "web-tree-sitter": "0.25.10",
-    "which": "6.0.1",
     "ws": "8.21.0",
     "xdg-basedir": "5.1.0",
     "yargs": "18.0.0",

+ 4 - 4
packages/opencode/specs/effect/facades.md

@@ -32,7 +32,7 @@ Caller-heavy batch, all merged:
 
 1. `src/config/config.ts`
 2. `src/provider/provider.ts`
-3. `src/file/index.ts`
+3. `../core/src/filesystem.ts`
 4. `src/lsp/index.ts`
 5. `src/mcp/index.ts`
 
@@ -168,7 +168,7 @@ Usually no.
 Prefer the direct form when there is only one expression:
 
 ```ts
-await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
+await Effect.runPromise(FileSystem.Service.use((svc) => svc.read({ path })))
 ```
 
 Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
@@ -179,7 +179,7 @@ These were the recurring mistakes and useful corrections from the first two batc
 
 1. Tests should usually provide the specific service layer, not `AppRuntime`.
 2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
-3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
+3. Location-scoped services may need both the service layer and the right location fixture. `FileSystem` tests, for example, provide `Location.Service` plus `FileSystem.locationLayer`.
 4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
 5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
 6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
@@ -201,7 +201,7 @@ Most of the original facade-removal backlog is already done. The practical remai
 - [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
 - [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
 - [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
-- [x] `src/file/index.ts` (`File`) - facades removed and merged
+- [x] `../core/src/filesystem.ts` (`FileSystem`) - legacy opencode service removed
 - [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
 - [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
 - [x] `src/config/config.ts` (`Config`) - facades removed and merged

+ 1 - 1
packages/opencode/specs/effect/guide.md

@@ -179,7 +179,7 @@ Intentional boundaries:
 In effectified code, yield existing services instead of dropping to ad hoc
 platform APIs.
 
-- Use `AppFileSystem.Service` instead of raw `fs/promises` for app file IO.
+- Use `FSUtil.Service` instead of raw `fs/promises` for app file IO.
 - Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
   legacy process helpers.
 - Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.

+ 1 - 1
packages/opencode/specs/effect/loose-ends.md

@@ -14,7 +14,7 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
 
 - [ ] `config/paths.ts` - split pure helpers from effectful helpers.
       Keep `fileInDirectory(...)` as a plain function.
-- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
+- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `FSUtil.Service` directly.
       Initial service surface should cover:
   - `projectFiles(...)`
   - `directories(...)`

+ 1 - 1
packages/opencode/specs/effect/migration.md

@@ -33,7 +33,7 @@ genuinely outside `AppLayer`.
 
 ## Platform Edges
 
-- Use `AppFileSystem.Service` instead of raw filesystem APIs in
+- Use `FSUtil.Service` instead of raw filesystem APIs in
   effectified services.
 - Use `AppProcess.Service` instead of raw process wrappers.
 - Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.

+ 2 - 2
packages/opencode/specs/effect/todo.md

@@ -72,7 +72,7 @@ P6  OA
 - `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
   process wrappers.
   Shrinks: direct spawn callsites and legacy process helpers.
-- `FS` AppFileSystem migration — prefer `AppFileSystem.Service` over raw
+- `FS` FSUtil migration — prefer `FSUtil.Service` over raw
   filesystem APIs.
   Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
 - `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
@@ -229,7 +229,7 @@ Current rules:
 
 ## Lower Priority Tracks
 
-- `PROC` / `FS` — continue AppProcess and AppFileSystem migrations as
+- `PROC` / `FS` — continue AppProcess and FSUtil migrations as
   focused PRs when touching relevant files.
 - `RT` — remove service-local runtime facades only when they are not an
   intentional boundary.

+ 3 - 3
packages/opencode/specs/effect/tools.md

@@ -11,7 +11,7 @@ The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-
 So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
 
 1. remove Promise and raw platform bridges inside individual tool bodies
-2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
+2. swap tool internals to Effect-native services like `FSUtil`, `HttpClient`, and `ChildProcessSpawner`
 3. keep tests and callers aligned with `yield* info.init()` and real service graphs
 
 ## Current shape
@@ -67,11 +67,11 @@ Most exported tools are already on the intended Effect-native shape. The remaini
 
 Current spot cleanups worth tracking:
 
-- [x] `read.ts` — streams through `AppFileSystem.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
+- [x] `read.ts` — streams through `FSUtil.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
 - [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
 - [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
 - [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
-- [x] `patch/index.ts` — apply path now returns `Effect` over `AppFileSystem.Service`; the parser and chunk replacer stay pure
+- [x] `patch/index.ts` — apply path now returns `Effect` over `FSUtil.Service`; the parser and chunk replacer stay pure
 
 Notable items that are already effectively on the target path and do not need separate migration bullets right now:
 

+ 3 - 3
packages/opencode/src/auth/index.ts

@@ -2,7 +2,7 @@ import path from "path"
 import { Effect, Layer, Record, Result, Schema, Context } from "effect"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { Global } from "@opencode-ai/core/global"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 
 export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
 
@@ -51,7 +51,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Au
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fsys = yield* AppFileSystem.Service
+    const fsys = yield* FSUtil.Service
     const decode = Schema.decodeUnknownOption(Info)
 
     const all = Effect.fn("Auth.all")(function* () {
@@ -91,6 +91,6 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
 
 export * as Auth from "."

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

@@ -1,10 +1,18 @@
 import { EOL } from "os"
 import { Effect } from "effect"
-import { File } from "../../../file"
-import { Ripgrep } from "@/file/ripgrep"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
+import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
 import { effectCmd } from "../../effect-cmd"
 import { cmd } from "../cmd"
 
+const filesystem = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
+  effect.pipe(
+    Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(process.cwd()) })),
+    Effect.provide(LocationServiceMap.layer),
+  )
+
 const FileSearchCommand = effectCmd({
   command: "search <query>",
   describe: "search files by query",
@@ -15,8 +23,8 @@ const FileSearchCommand = effectCmd({
       description: "Search query",
     }),
   handler: Effect.fn("Cli.debug.file.search")(function* (args) {
-    const results = yield* File.Service.use((svc) => svc.search({ query: args.query }))
-    process.stdout.write(results.join(EOL) + EOL)
+    const results = yield* filesystem(FileSystem.Service.use((svc) => svc.find({ query: args.query })))
+    process.stdout.write(results.map((item) => item.path).join(EOL) + EOL)
   }),
 })
 
@@ -30,21 +38,11 @@ const FileReadCommand = effectCmd({
       description: "File path to read",
     }),
   handler: Effect.fn("Cli.debug.file.read")(function* (args) {
-    const content = yield* File.Service.use((svc) => svc.read(args.path))
+    const content = yield* filesystem(FileSystem.Service.use((svc) => svc.read({ path: RelativePath.make(args.path) })))
     process.stdout.write(JSON.stringify(content, null, 2) + EOL)
   }),
 })
 
-const FileStatusCommand = effectCmd({
-  command: "status",
-  describe: "show file status information",
-  builder: (yargs) => yargs,
-  handler: Effect.fn("Cli.debug.file.status")(function* () {
-    const status = yield* File.Service.use((svc) => svc.status())
-    process.stdout.write(JSON.stringify(status, null, 2) + EOL)
-  }),
-})
-
 const FileListCommand = effectCmd({
   command: "list <path>",
   describe: "list files in a directory",
@@ -55,7 +53,7 @@ const FileListCommand = effectCmd({
       description: "File path to list",
     }),
   handler: Effect.fn("Cli.debug.file.list")(function* (args) {
-    const files = yield* File.Service.use((svc) => svc.list(args.path))
+    const files = yield* filesystem(FileSystem.Service.use((svc) => svc.list({ path: RelativePath.make(args.path) })))
     process.stdout.write(JSON.stringify(files, null, 2) + EOL)
   }),
 })
@@ -81,7 +79,6 @@ export const FileCommand = cmd({
   builder: (yargs) =>
     yargs
       .command(FileReadCommand)
-      .command(FileStatusCommand)
       .command(FileListCommand)
       .command(FileSearchCommand)
       .command(FileTreeCommand)

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

@@ -1,6 +1,6 @@
 import { EOL } from "os"
 import { Effect, Stream } from "effect"
-import { Ripgrep } from "../../../file/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { effectCmd } from "../../effect-cmd"
 import { cmd } from "../cmd"
 import { InstanceRef } from "@/effect/instance-ref"

+ 2 - 2
packages/opencode/src/cli/cmd/import.ts

@@ -9,7 +9,7 @@ import { InstanceRef } from "@/effect/instance-ref"
 import { ShareNext } from "@/share/share-next"
 import { EOL } from "os"
 import path from "path"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Effect, Schema } from "effect"
 import type { InstanceContext } from "@/project/instance-context"
 
@@ -98,7 +98,7 @@ export const ImportCommand = effectCmd({
 
 const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
   const share = yield* ShareNext.Service
-  const fs = yield* AppFileSystem.Service
+  const fs = yield* FSUtil.Service
   const { db } = yield* Database.Service
 
   let exportData: ExportData | undefined

+ 4 - 4
packages/opencode/src/cli/cmd/run/variant.shared.ts

@@ -7,7 +7,7 @@
 // so your last-used variant sticks. Cycling (ctrl+t) updates both the active
 // variant and the persisted file.
 import path from "path"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Context, Effect, Layer } from "effect"
 import { makeRuntime } from "@/effect/run-service"
 import { Global } from "@opencode-ai/core/global"
@@ -135,12 +135,12 @@ function state(value: unknown): ModelState {
   }
 }
 
-function createLayer(fs = AppFileSystem.defaultLayer) {
+function createLayer(fs = FSUtil.defaultLayer) {
   return Layer.fresh(
     Layer.effect(
       Service,
       Effect.gen(function* () {
-        const file = yield* AppFileSystem.Service
+        const file = yield* FSUtil.Service
 
         const read = Effect.fn("RunVariant.read")(function* () {
           return yield* file.readJson(MODEL_FILE).pipe(
@@ -196,7 +196,7 @@ function createLayer(fs = AppFileSystem.defaultLayer) {
 }
 
 /** @internal Exported for testing. */
-export function createVariantRuntime(fs = AppFileSystem.defaultLayer): VariantRuntime {
+export function createVariantRuntime(fs = FSUtil.defaultLayer): VariantRuntime {
   const runtime = makeRuntime(Service, createLayer(fs))
   return {
     resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),

+ 1 - 1
packages/opencode/src/cli/cmd/session.ts

@@ -12,7 +12,7 @@ import { Process } from "@/util/process"
 import { NotFoundError } from "@/storage/storage"
 import { EOL } from "os"
 import path from "path"
-import { which } from "../../util/which"
+import { which } from "@opencode-ai/core/util/which"
 
 function pagerCmd(): string[] {
   const lessOptions = ["-R", "-S"]

+ 3 - 3
packages/opencode/src/cli/cmd/tui/config/tui.ts

@@ -11,7 +11,7 @@ import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { isRecord } from "@/util/record"
 import { Global } from "@opencode-ai/core/global"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { CurrentWorkingDirectory } from "./cwd"
 import { ConfigPlugin } from "@/config/plugin"
 import { TuiKeybind } from "./keybind"
@@ -97,7 +97,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
 }
 
 const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
-  const afs = yield* AppFileSystem.Service
+  const afs = yield* FSUtil.Service
   let appliedOrder = 0
 
   const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
@@ -295,7 +295,7 @@ export const layer = Layer.effect(
   }).pipe(Effect.withSpan("TuiConfig.layer")),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer))
 
 const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 1 - 1
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -20,7 +20,7 @@ const writeWithStdin = (cmd: string[], text: string): Promise<void> =>
 
 // Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup
 const getWhich = lazy(async () => {
-  const { which } = await import("../../../../util/which")
+  const { which } = await import("@opencode-ai/core/util/which")
   return which
 })
 

+ 4 - 4
packages/opencode/src/config/config.ts

@@ -16,7 +16,7 @@ import { existsSync } from "fs"
 import { Account } from "@/account/account"
 import { isRecord } from "@/util/record"
 import type { ConsoleState } from "./console-state"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { InstanceState } from "@/effect/instance-state"
 import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -384,7 +384,7 @@ export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoEr
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const authSvc = yield* Auth.Service
     const accountSvc = yield* Account.Service
     const env = yield* Env.Service
@@ -796,7 +796,7 @@ export const layer = Layer.effect(
           },
         }
       },
-      Effect.provideService(AppFileSystem.Service, fs),
+      Effect.provideService(FSUtil.Service, fs),
     )
 
     const state = yield* InstanceState.make<State>(
@@ -876,7 +876,7 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(
   Layer.provide(EffectFlock.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Env.defaultLayer),
   Layer.provide(Auth.defaultLayer),
   Layer.provide(Account.defaultLayer),

+ 3 - 3
packages/opencode/src/config/paths.ts

@@ -5,14 +5,14 @@ import { Flag } from "@opencode-ai/core/flag/flag"
 import { Global } from "@opencode-ai/core/global"
 import { unique } from "remeda"
 import * as Effect from "effect/Effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 
 export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
   name: string,
   directory: string,
   worktree?: string,
 ) {
-  const afs = yield* AppFileSystem.Service
+  const afs = yield* FSUtil.Service
   return (yield* afs.up({
     targets: [`${name}.jsonc`, `${name}.json`],
     start: directory,
@@ -21,7 +21,7 @@ export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
 })
 
 export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
-  const afs = yield* AppFileSystem.Service
+  const afs = yield* FSUtil.Service
   return unique([
     Global.Path.config,
     ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG

+ 3 - 3
packages/opencode/src/control-plane/workspace.ts

@@ -11,7 +11,7 @@ import { Auth } from "@/auth"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import * as Log from "@opencode-ai/core/util/log"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { ProjectV2 } from "@opencode-ai/core/project"
@@ -177,7 +177,7 @@ export const layer = Layer.effect(
     const events = yield* EventV2Bridge.Service
     const vcs = yield* Vcs.Service
     const flags = yield* RuntimeFlags.Service
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const { db } = yield* Database.Service
     const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
     const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
@@ -1005,7 +1005,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(SessionPrompt.defaultLayer),
   Layer.provide(Project.defaultLayer),
   Layer.provide(Vcs.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(EventV2Bridge.defaultLayer),
   Layer.provide(FetchHttpClient.layer),

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

@@ -2,15 +2,13 @@ import { Layer, ManagedRuntime } from "effect"
 import { attach } from "./run-service"
 import * as Observability from "@opencode-ai/core/effect/observability"
 
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Database } from "@opencode-ai/core/database/database"
 import { Auth } from "@/auth"
 import { Account } from "@/account/account"
 import { Config } from "@/config/config"
 import { Git } from "@/git"
-import { Ripgrep } from "@/file/ripgrep"
-import { File } from "@/file"
-import { FileWatcher } from "@/file/watcher"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { Storage } from "@/storage/storage"
 import { Snapshot } from "@/snapshot"
 import { Plugin } from "@/plugin"
@@ -59,15 +57,13 @@ import { EventV2Bridge } from "@/event-v2-bridge"
 
 export const AppLayer = Layer.mergeAll(
   Npm.defaultLayer,
-  AppFileSystem.defaultLayer,
+  FSUtil.defaultLayer,
   Database.defaultLayer,
   Auth.defaultLayer,
   Account.defaultLayer,
   Config.defaultLayer,
   Git.defaultLayer,
   Ripgrep.defaultLayer,
-  File.defaultLayer,
-  FileWatcher.defaultLayer,
   Storage.defaultLayer,
   Snapshot.defaultLayer,
   Plugin.defaultLayer,

+ 0 - 4
packages/opencode/src/effect/bootstrap-runtime.ts

@@ -2,10 +2,8 @@ import { Layer, ManagedRuntime } from "effect"
 
 import { Plugin } from "@/plugin"
 import { LSP } from "@/lsp/lsp"
-import { FileWatcher } from "@/file/watcher"
 import { Format } from "@/format"
 import { ShareNext } from "@/share/share-next"
-import { File } from "@/file"
 import { Vcs } from "@/project/vcs"
 import { Snapshot } from "@/snapshot"
 import { Config } from "@/config/config"
@@ -18,8 +16,6 @@ export const BootstrapLayer = Layer.mergeAll(
   ShareNext.defaultLayer,
   Format.defaultLayer,
   LSP.defaultLayer,
-  File.defaultLayer,
-  FileWatcher.defaultLayer,
   Vcs.defaultLayer,
   Snapshot.defaultLayer,
 ).pipe(Layer.provide(Observability.layer))

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

@@ -1,654 +0,0 @@
-import { EventV2 } from "@opencode-ai/core/event"
-import { serviceUse } from "@opencode-ai/core/effect/service-use"
-import { InstanceState } from "@/effect/instance-state"
-
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Git } from "@/git"
-import { Effect, Layer, Context, Schema, Scope } from "effect"
-import * as Stream from "effect/Stream"
-import { formatPatch, structuredPatch } from "diff"
-import fuzzysort from "fuzzysort"
-import ignore from "ignore"
-import path from "path"
-import { Global } from "@opencode-ai/core/global"
-import { containsPath } from "../project/instance-context"
-import * as Log from "@opencode-ai/core/util/log"
-import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
-import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema"
-
-export const Info = Schema.Struct({
-  path: Schema.String,
-  added: NonNegativeInt,
-  removed: NonNegativeInt,
-  status: Schema.Literals(["added", "deleted", "modified"]),
-}).annotate({ identifier: "File" })
-export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
-
-export const Node = Schema.Struct({
-  name: Schema.String,
-  path: Schema.String,
-  absolute: Schema.String,
-  type: Schema.Literals(["file", "directory"]),
-  ignored: Schema.Boolean,
-}).annotate({ identifier: "FileNode" })
-export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
-
-const Hunk = Schema.Struct({
-  oldStart: NonNegativeInt,
-  oldLines: NonNegativeInt,
-  newStart: NonNegativeInt,
-  newLines: NonNegativeInt,
-  lines: Schema.Array(Schema.String),
-})
-
-const Patch = Schema.Struct({
-  oldFileName: Schema.String,
-  newFileName: Schema.String,
-  oldHeader: Schema.optional(Schema.String),
-  newHeader: Schema.optional(Schema.String),
-  hunks: Schema.Array(Hunk),
-  index: Schema.optional(Schema.String),
-})
-
-export const Content = Schema.Struct({
-  type: Schema.Literals(["text", "binary"]),
-  content: Schema.String,
-  diff: Schema.optional(Schema.String),
-  patch: Schema.optional(Patch),
-  encoding: Schema.optional(Schema.Literal("base64")),
-  mimeType: Schema.optional(Schema.String),
-}).annotate({ identifier: "FileContent" })
-export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
-
-export const Event = {
-  Edited: EventV2.define({
-    type: "file.edited",
-    schema: {
-      file: Schema.String,
-    },
-  }),
-}
-
-const log = Log.create({ service: "file" })
-
-const binary = new Set([
-  "exe",
-  "dll",
-  "pdb",
-  "bin",
-  "so",
-  "dylib",
-  "o",
-  "a",
-  "lib",
-  "wav",
-  "mp3",
-  "ogg",
-  "oga",
-  "ogv",
-  "ogx",
-  "flac",
-  "aac",
-  "wma",
-  "m4a",
-  "weba",
-  "mp4",
-  "avi",
-  "mov",
-  "wmv",
-  "flv",
-  "webm",
-  "mkv",
-  "zip",
-  "tar",
-  "gz",
-  "gzip",
-  "bz",
-  "bz2",
-  "bzip",
-  "bzip2",
-  "7z",
-  "rar",
-  "xz",
-  "lz",
-  "z",
-  "pdf",
-  "doc",
-  "docx",
-  "ppt",
-  "pptx",
-  "xls",
-  "xlsx",
-  "dmg",
-  "iso",
-  "img",
-  "vmdk",
-  "ttf",
-  "otf",
-  "woff",
-  "woff2",
-  "eot",
-  "sqlite",
-  "db",
-  "mdb",
-  "apk",
-  "ipa",
-  "aab",
-  "xapk",
-  "app",
-  "pkg",
-  "deb",
-  "rpm",
-  "snap",
-  "flatpak",
-  "appimage",
-  "msi",
-  "msp",
-  "jar",
-  "war",
-  "ear",
-  "class",
-  "kotlin_module",
-  "dex",
-  "vdex",
-  "odex",
-  "oat",
-  "art",
-  "wasm",
-  "wat",
-  "bc",
-  "ll",
-  "s",
-  "ko",
-  "sys",
-  "drv",
-  "efi",
-  "rom",
-  "com",
-])
-
-const image = new Set([
-  "png",
-  "jpg",
-  "jpeg",
-  "gif",
-  "bmp",
-  "webp",
-  "ico",
-  "tif",
-  "tiff",
-  "svg",
-  "svgz",
-  "avif",
-  "apng",
-  "jxl",
-  "heic",
-  "heif",
-  "raw",
-  "cr2",
-  "nef",
-  "arw",
-  "dng",
-  "orf",
-  "raf",
-  "pef",
-  "x3f",
-])
-
-const text = new Set([
-  "ts",
-  "tsx",
-  "mts",
-  "cts",
-  "mtsx",
-  "ctsx",
-  "js",
-  "jsx",
-  "mjs",
-  "cjs",
-  "sh",
-  "bash",
-  "zsh",
-  "fish",
-  "ps1",
-  "psm1",
-  "cmd",
-  "bat",
-  "json",
-  "jsonc",
-  "json5",
-  "yaml",
-  "yml",
-  "toml",
-  "md",
-  "mdx",
-  "txt",
-  "xml",
-  "html",
-  "htm",
-  "css",
-  "scss",
-  "sass",
-  "less",
-  "graphql",
-  "gql",
-  "sql",
-  "ini",
-  "cfg",
-  "conf",
-  "env",
-])
-
-const textName = new Set([
-  "dockerfile",
-  "makefile",
-  ".gitignore",
-  ".gitattributes",
-  ".editorconfig",
-  ".npmrc",
-  ".nvmrc",
-  ".prettierrc",
-  ".eslintrc",
-])
-
-const mime: Record<string, string> = {
-  png: "image/png",
-  jpg: "image/jpeg",
-  jpeg: "image/jpeg",
-  gif: "image/gif",
-  bmp: "image/bmp",
-  webp: "image/webp",
-  ico: "image/x-icon",
-  tif: "image/tiff",
-  tiff: "image/tiff",
-  svg: "image/svg+xml",
-  svgz: "image/svg+xml",
-  avif: "image/avif",
-  apng: "image/apng",
-  jxl: "image/jxl",
-  heic: "image/heic",
-  heif: "image/heif",
-}
-
-type Entry = { files: string[]; dirs: string[] }
-
-const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
-const name = (file: string) => path.basename(file).toLowerCase()
-const isImageByExtension = (file: string) => image.has(ext(file))
-const isTextByExtension = (file: string) => text.has(ext(file))
-const isTextByName = (file: string) => textName.has(name(file))
-const isBinaryByExtension = (file: string) => binary.has(ext(file))
-const isImage = (mimeType: string) => mimeType.startsWith("image/")
-const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
-
-function shouldEncode(mimeType: string) {
-  const type = mimeType.toLowerCase()
-  log.debug("shouldEncode", { type })
-  if (!type) return false
-  if (type.startsWith("text/")) return false
-  if (type.includes("charset=")) return false
-  const top = type.split("/", 2)[0]
-  return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
-}
-
-const hidden = (item: string) => {
-  const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
-  return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
-}
-
-const sortHiddenLast = (items: string[], prefer: boolean) => {
-  if (prefer) return items
-  const visible: string[] = []
-  const hiddenItems: string[] = []
-  for (const item of items) {
-    if (hidden(item)) hiddenItems.push(item)
-    else visible.push(item)
-  }
-  return [...visible, ...hiddenItems]
-}
-
-interface State {
-  cache: Entry
-}
-
-export interface Interface {
-  readonly init: () => Effect.Effect<void>
-  readonly status: () => Effect.Effect<Info[]>
-  readonly read: (file: string) => Effect.Effect<Content>
-  readonly list: (dir?: string) => Effect.Effect<Node[]>
-  readonly search: (input: {
-    query: string
-    limit?: number
-    dirs?: boolean
-    type?: "file" | "directory"
-  }) => Effect.Effect<string[]>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
-
-export const use = serviceUse(Service)
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const appFs = yield* AppFileSystem.Service
-    const rg = yield* Ripgrep.Service
-    const git = yield* Git.Service
-    const scope = yield* Scope.Scope
-
-    const state = yield* InstanceState.make<State>(
-      Effect.fn("File.state")(() =>
-        Effect.succeed({
-          cache: { files: [], dirs: [] } as Entry,
-        }),
-      ),
-    )
-
-    const scan = Effect.fn("File.scan")(function* () {
-      const ctx = yield* InstanceState.context
-      if (ctx.directory === path.parse(ctx.directory).root) return
-      const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global"
-      const next: Entry = { files: [], dirs: [] }
-
-      if (isGlobalHome) {
-        const dirs = new Set<string>()
-        const protectedNames = Protected.names()
-        const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
-        const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
-        const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
-        const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => []))
-
-        for (const entry of top) {
-          if (entry.type !== "directory") continue
-          if (shouldIgnoreName(entry.name)) continue
-          dirs.add(entry.name + "/")
-
-          const base = path.join(ctx.directory, entry.name)
-          const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
-          for (const child of children) {
-            if (child.type !== "directory") continue
-            if (shouldIgnoreNested(child.name)) continue
-            dirs.add(entry.name + "/" + child.name + "/")
-          }
-        }
-
-        next.dirs = Array.from(dirs).toSorted()
-      } else {
-        const files = yield* rg.files({ cwd: ctx.directory }).pipe(
-          Stream.runCollect,
-          Effect.map((chunk) => [...chunk]),
-        )
-        const seen = new Set<string>()
-        for (const file of files) {
-          next.files.push(file)
-          let current = file
-          while (true) {
-            const dir = path.dirname(current)
-            if (dir === ".") break
-            if (dir === current) break
-            current = dir
-            if (seen.has(dir)) continue
-            seen.add(dir)
-            next.dirs.push(dir + "/")
-          }
-        }
-      }
-
-      const s = yield* InstanceState.get(state)
-      s.cache = next
-    })
-
-    let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
-
-    const ensure = Effect.fn("File.ensure")(function* () {
-      yield* cachedScan
-      cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
-    })
-
-    const gitText = Effect.fnUntraced(function* (args: string[]) {
-      return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text()
-    })
-
-    const init = Effect.fn("File.init")(function* () {
-      yield* ensure().pipe(Effect.forkIn(scope))
-    })
-
-    const status = Effect.fn("File.status")(function* () {
-      const ctx = yield* InstanceState.context
-      if (ctx.project.vcs !== "git") return []
-
-      const diffOutput = yield* gitText([
-        "-c",
-        "core.fsmonitor=false",
-        "-c",
-        "core.quotepath=false",
-        "diff",
-        "--numstat",
-        "HEAD",
-      ])
-
-      const changed: Info[] = []
-
-      if (diffOutput.trim()) {
-        for (const line of diffOutput.trim().split("\n")) {
-          const [added, removed, file] = line.split("\t")
-          changed.push({
-            path: file,
-            added: added === "-" ? 0 : parseInt(added, 10),
-            removed: removed === "-" ? 0 : parseInt(removed, 10),
-            status: "modified",
-          })
-        }
-      }
-
-      const untrackedOutput = yield* gitText([
-        "-c",
-        "core.fsmonitor=false",
-        "-c",
-        "core.quotepath=false",
-        "ls-files",
-        "--others",
-        "--exclude-standard",
-      ])
-
-      if (untrackedOutput.trim()) {
-        for (const file of untrackedOutput.trim().split("\n")) {
-          const content = yield* appFs
-            .readFileString(path.join(ctx.directory, file))
-            .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
-          if (content === undefined) continue
-          changed.push({
-            path: file,
-            added: content.split("\n").length,
-            removed: 0,
-            status: "added",
-          })
-        }
-      }
-
-      const deletedOutput = yield* gitText([
-        "-c",
-        "core.fsmonitor=false",
-        "-c",
-        "core.quotepath=false",
-        "diff",
-        "--name-only",
-        "--diff-filter=D",
-        "HEAD",
-      ])
-
-      if (deletedOutput.trim()) {
-        for (const file of deletedOutput.trim().split("\n")) {
-          changed.push({
-            path: file,
-            added: 0,
-            removed: 0,
-            status: "deleted",
-          })
-        }
-      }
-
-      return changed.map((item) => {
-        const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path)
-        return {
-          ...item,
-          path: path.relative(ctx.directory, full),
-        }
-      })
-    })
-
-    const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
-      using _ = log.time("read", { file })
-      const ctx = yield* InstanceState.context
-      const full = path.join(ctx.directory, file)
-
-      if (!containsPath(full, ctx)) {
-        throw new Error("Access denied: path escapes project directory")
-      }
-
-      if (isImageByExtension(file)) {
-        const exists = yield* appFs.existsSafe(full)
-        if (exists) {
-          const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
-          return {
-            type: "text" as const,
-            content: Buffer.from(bytes).toString("base64"),
-            mimeType: getImageMimeType(file),
-            encoding: "base64" as const,
-          }
-        }
-        return { type: "text" as const, content: "" }
-      }
-
-      const knownText = isTextByExtension(file) || isTextByName(file)
-
-      if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
-
-      const exists = yield* appFs.existsSafe(full)
-      if (!exists) return { type: "text" as const, content: "" }
-
-      const mimeType = AppFileSystem.mimeType(full)
-      const encode = knownText ? false : shouldEncode(mimeType)
-
-      if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
-
-      if (encode) {
-        const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
-        return {
-          type: "text" as const,
-          content: Buffer.from(bytes).toString("base64"),
-          mimeType,
-          encoding: "base64" as const,
-        }
-      }
-
-      const content = yield* appFs.readFileString(full).pipe(
-        Effect.map((s) => s.trim()),
-        Effect.catch(() => Effect.succeed("")),
-      )
-
-      if (ctx.project.vcs === "git") {
-        let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
-        if (!diff.trim()) {
-          diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
-        }
-        if (diff.trim()) {
-          const original = yield* git.show(ctx.directory, "HEAD", file)
-          const patch = structuredPatch(file, file, original, content, "old", "new", {
-            context: Infinity,
-            ignoreWhitespace: true,
-          })
-          return { type: "text" as const, content, patch, diff: formatPatch(patch) }
-        }
-        return { type: "text" as const, content }
-      }
-
-      return { type: "text" as const, content }
-    })
-
-    const list = Effect.fn("File.list")(function* (dir?: string) {
-      const ctx = yield* InstanceState.context
-      const exclude = [".git", ".DS_Store"]
-      let ignored = (_: string) => false
-      if (ctx.project.vcs === "git") {
-        const ig = ignore()
-        const gitignore = path.join(ctx.worktree, ".gitignore")
-        const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
-        if (gitignoreText) ig.add(gitignoreText)
-        const ignoreFile = path.join(ctx.worktree, ".ignore")
-        const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
-        if (ignoreText) ig.add(ignoreText)
-        ignored = ig.ignores.bind(ig)
-      }
-
-      const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
-      if (!containsPath(resolved, ctx)) {
-        throw new Error("Access denied: path escapes project directory")
-      }
-
-      const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
-
-      const nodes: Node[] = []
-      for (const entry of entries) {
-        if (exclude.includes(entry.name)) continue
-        const absolute = path.join(resolved, entry.name)
-        const file = path.relative(ctx.directory, absolute)
-        const type = entry.type === "directory" ? "directory" : "file"
-        nodes.push({
-          name: entry.name,
-          path: file,
-          absolute,
-          type,
-          ignored: ignored(type === "directory" ? file + "/" : file),
-        })
-      }
-      return nodes.sort((a, b) => {
-        if (a.type !== b.type) return a.type === "directory" ? -1 : 1
-        return a.name.localeCompare(b.name)
-      })
-    })
-
-    const search = Effect.fn("File.search")(function* (input: {
-      query: string
-      limit?: number
-      dirs?: boolean
-      type?: "file" | "directory"
-    }) {
-      yield* ensure()
-      const { cache } = yield* InstanceState.get(state)
-
-      const query = input.query.trim()
-      const limit = input.limit ?? 100
-      const kind = input.type ?? (input.dirs === false ? "file" : "all")
-      log.info("search", { query, kind })
-
-      const preferHidden = query.startsWith(".") || query.includes("/.")
-
-      if (!query) {
-        if (kind === "file") return cache.files.slice(0, limit)
-        return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
-      }
-
-      const items = kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
-
-      const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
-      const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
-      const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
-
-      log.info("search", { query, kind, results: output.length })
-      return output
-    })
-
-    log.info("init")
-    return Service.of({ init, status, read, list, search })
-  }),
-)
-
-export const defaultLayer = layer.pipe(
-  Layer.provide(Ripgrep.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
-  Layer.provide(Git.defaultLayer),
-)
-
-export * as File from "."

+ 0 - 172
packages/opencode/src/file/watcher.ts

@@ -1,172 +0,0 @@
-import { Cause, Effect, Layer, Context, Schema } from "effect"
-// @ts-ignore
-import { createWrapper } from "@parcel/watcher/wrapper"
-import type ParcelWatcher from "@parcel/watcher"
-import { readdir, realpath } from "fs/promises"
-import path from "path"
-import { EventV2 } from "@opencode-ai/core/event"
-import { EventV2Bridge } from "@/event-v2-bridge"
-import { EffectBridge } from "@/effect/bridge"
-import { InstanceState } from "@/effect/instance-state"
-import { Flag } from "@opencode-ai/core/flag/flag"
-import { Git } from "@/git"
-import { lazy } from "@/util/lazy"
-import { Config } from "@/config/config"
-import { FileIgnore } from "./ignore"
-import { Protected } from "./protected"
-import * as Log from "@opencode-ai/core/util/log"
-
-declare const OPENCODE_LIBC: string | undefined
-
-const log = Log.create({ service: "file.watcher" })
-const SUBSCRIBE_TIMEOUT_MS = 10_000
-
-export const Event = {
-  Updated: EventV2.define({
-    type: "file.watcher.updated",
-    schema: {
-      file: Schema.String,
-      event: Schema.Literals(["add", "change", "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
-  }
-})
-
-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)
-  })
-}
-
-export const hasNativeBinding = () => !!watcher()
-
-export interface Interface {
-  readonly init: () => Effect.Effect<void>
-}
-
-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
-    const events = yield* EventV2Bridge.Service
-
-    const state = yield* InstanceState.make(
-      Effect.fn("FileWatcher.state")(
-        function* () {
-          if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
-
-          const ctx = yield* InstanceState.context
-
-          log.info("init", { directory: ctx.directory })
-
-          const backend = getBackend()
-          if (!backend) {
-            log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
-            return
-          }
-
-          const w = watcher()
-          if (!w) return
-
-          log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
-          const bridge = yield* EffectBridge.make()
-          const subs: ParcelWatcher.AsyncSubscription[] = []
-          yield* Effect.addFinalizer(() =>
-            Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
-          )
-
-          const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
-            // if (err) return
-            for (const evt of evts) {
-              if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" }))
-              if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" }))
-              if (evt.type === "delete") bridge.fork(events.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 cfg = yield* config.get()
-          const cfgIgnores = cfg.watcher?.ignore ?? []
-
-          if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
-            yield* Effect.forkScoped(
-              subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
-            )
-          }
-
-          if (ctx.project.vcs === "git") {
-            const result = yield* git.run(["rev-parse", "--git-dir"], {
-              cwd: ctx.worktree,
-            })
-            const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
-            const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
-            if (
-              vcsDir &&
-              !cfgIgnores.includes(".git") &&
-              !cfgIgnores.includes(vcsDir) &&
-              (!resolved || !cfgIgnores.includes(resolved))
-            ) {
-              const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
-                (entry) => entry !== "HEAD",
-              )
-              yield* Effect.forkScoped(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)
-      }),
-    })
-  }),
-)
-
-export const defaultLayer = layer.pipe(
-  Layer.provide(Config.defaultLayer),
-  Layer.provide(Git.defaultLayer),
-  Layer.provide(EventV2Bridge.defaultLayer),
-)
-
-export * as FileWatcher from "./watcher"

+ 1 - 1
packages/opencode/src/format/formatter.ts

@@ -2,7 +2,7 @@ import { Npm } from "@opencode-ai/core/npm"
 import type { InstanceContext } from "../project/instance-context"
 import { Filesystem } from "@/util/filesystem"
 import { Process } from "@/util/process"
-import { which } from "../util/which"
+import { which } from "@opencode-ai/core/util/which"
 
 export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {
   experimentalOxfmt: boolean

+ 1 - 1
packages/opencode/src/lsp/server.ts

@@ -9,7 +9,7 @@ import { Filesystem } from "@/util/filesystem"
 import type { InstanceContext } from "../project/instance-context"
 import { Archive } from "@/util/archive"
 import { Process } from "@/util/process"
-import { which } from "../util/which"
+import { which } from "@opencode-ai/core/util/which"
 import { Module } from "@opencode-ai/core/util/module"
 import { spawn } from "./launch"
 import { Npm } from "@opencode-ai/core/npm"

+ 3 - 6
packages/opencode/src/mcp/auth.ts

@@ -2,7 +2,7 @@ import path from "path"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { Global } from "@opencode-ai/core/global"
 import { Effect, Layer, Context, Option, Schema } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 
 export const Tokens = Schema.Struct({
@@ -59,7 +59,7 @@ export const use = serviceUse(Service)
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const flock = yield* EffectFlock.Service
 
     const read = Effect.fn("McpAuth.read")(function* () {
@@ -166,9 +166,6 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(
-  Layer.provide(EffectFlock.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
-)
+export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
 
 export * as McpAuth from "./auth"

+ 2 - 2
packages/opencode/src/mcp/index.ts

@@ -18,7 +18,7 @@ import * as Log from "@opencode-ai/core/util/log"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { withTimeout } from "@/util/timeout"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
 import { McpOAuthCallback } from "./oauth-callback"
 import { McpAuth } from "./auth"
@@ -975,7 +975,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(EventV2Bridge.defaultLayer),
   Layer.provide(Config.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
 )
 
 export * as MCP from "."

+ 4 - 4
packages/opencode/src/patch/index.ts

@@ -1,6 +1,6 @@
 import { Effect, Schema } from "effect"
 import * as path from "path"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import * as Log from "@opencode-ai/core/util/log"
 import * as Bom from "../util/bom"
 
@@ -519,7 +519,7 @@ export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function*
     return yield* Effect.fail(new Error("No files were modified."))
   }
 
-  const fs = yield* AppFileSystem.Service
+  const fs = yield* FSUtil.Service
 
   const added: string[] = []
   const modified: string[] = []
@@ -574,7 +574,7 @@ type MaybeApplyPatchVerifiedResult =
   | { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
   | { type: MaybeApplyPatchVerified.NotApplyPatch }
 
-// Effectful verified-parse: needs AppFileSystem.Service to read existing files
+// Effectful verified-parse: needs FSUtil.Service to read existing files
 export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatchVerified")(function* (
   argv: string[],
   cwd: string,
@@ -596,7 +596,7 @@ export const maybeParseApplyPatchVerified = Effect.fn("Patch.maybeParseApplyPatc
 
   switch (result.type) {
     case MaybeApplyPatch.Body: {
-      const fs = yield* AppFileSystem.Service
+      const fs = yield* FSUtil.Service
       const args = result.args
       const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
       const changes = new Map<string, ApplyPatchFileChange>()

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

@@ -1,12 +1,10 @@
 import { Plugin } from "../plugin"
 import { Format } from "../format"
 import { LSP } from "@/lsp/lsp"
-import { File } from "../file"
 import { Snapshot } from "../snapshot"
 import * as Project from "./project"
 import * as Vcs from "./vcs"
 import { InstanceState } from "@/effect/instance-state"
-import { FileWatcher } from "@/file/watcher"
 import { ShareNext } from "@/share/share-next"
 import { Effect, Layer } from "effect"
 import { Config } from "@/config/config"
@@ -23,8 +21,6 @@ export const layer = Layer.effect(
     // InstanceStore imports only the lightweight tag from bootstrap-service.ts,
     // so it can depend on bootstrap without importing this implementation graph.
     const config = yield* Config.Service
-    const file = yield* File.Service
-    const fileWatcher = yield* FileWatcher.Service
     const format = yield* Format.Service
     const lsp = yield* LSP.Service
     const plugin = yield* Plugin.Service
@@ -44,7 +40,7 @@ export const layer = Layer.effect(
       // Each service self-manages its own slow work via Effect.forkScoped against
       // its per-instance state scope. We just await materialization here.
       yield* Effect.forEach(
-        [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
+        [reference, lsp, shareNext, format, vcs, snapshot, project],
         (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
         { concurrency: "unbounded", discard: true },
       ).pipe(Effect.withSpan("InstanceBootstrap.init"))
@@ -57,8 +53,6 @@ export const layer = Layer.effect(
 export const defaultLayer: Layer.Layer<Service> = layer.pipe(
   Layer.provide([
     Config.defaultLayer,
-    File.defaultLayer,
-    FileWatcher.defaultLayer,
     Format.defaultLayer,
     LSP.defaultLayer,
     Plugin.defaultLayer,

+ 3 - 3
packages/opencode/src/project/instance-context.ts

@@ -1,5 +1,5 @@
 import { LocalContext } from "@/util/local-context"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import type * as Project from "./project"
 
 export interface InstanceContext {
@@ -16,9 +16,9 @@ export const context = LocalContext.create<InstanceContext>("instance")
  * Paths within the worktree but outside the working directory should not trigger external_directory permission.
  */
 export function containsPath(filepath: string, ctx: InstanceContext): boolean {
-  if (AppFileSystem.contains(ctx.directory, filepath)) return true
+  if (FSUtil.contains(ctx.directory, filepath)) return true
   // Non-git projects set worktree to "/" which would match ANY absolute path.
   // Skip worktree check in this case to preserve external_directory permissions.
   if (ctx.worktree === "/") return false
-  return AppFileSystem.contains(ctx.worktree, filepath)
+  return FSUtil.contains(ctx.worktree, filepath)
 }

+ 4 - 4
packages/opencode/src/project/instance-store.ts

@@ -3,7 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { InstanceRef } from "@/effect/instance-ref"
 import { disposeInstance as runDisposers } from "@/effect/instance-registry"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
 import { type InstanceContext } from "./instance-context"
 import { InstanceBootstrap } from "./bootstrap-service"
@@ -104,7 +104,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
     })
 
     const load = (input: LoadInput): Effect.Effect<InstanceContext> => {
-      const directory = AppFileSystem.resolve(input.directory)
+      const directory = FSUtil.resolve(input.directory)
       return Effect.uninterruptibleMask((restore) =>
         Effect.gen(function* () {
           const existing = cache.get(directory)
@@ -122,7 +122,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
     }
 
     const reload = (input: LoadInput): Effect.Effect<InstanceContext> => {
-      const directory = AppFileSystem.resolve(input.directory)
+      const directory = FSUtil.resolve(input.directory)
       return Effect.uninterruptibleMask((restore) =>
         Effect.gen(function* () {
           const previous = cache.get(directory)
@@ -153,7 +153,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
     })
 
     const disposeDirectory = Effect.fn("InstanceStore.disposeDirectory")(function* (input: string) {
-      const directory = AppFileSystem.resolve(input)
+      const directory = FSUtil.resolve(input)
       const entry = cache.get(directory)
       if (!entry) return
       const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)

+ 5 - 5
packages/opencode/src/project/project.ts

@@ -6,12 +6,12 @@ import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
 import * as Log from "@opencode-ai/core/util/log"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { GlobalBus } from "@/bus/global"
-import { which } from "../util/which"
+import { which } from "@opencode-ai/core/util/which"
 import { Command } from "@/command"
 import { InstanceState } from "@/effect/instance-state"
 import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { AppProcess } from "@opencode-ai/core/process"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -135,7 +135,7 @@ type GitResult = { code: number; text: string; stderr: string }
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const proc = yield* AppProcess.Service
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
     const projectV2 = yield* ProjectV2.Service
@@ -326,7 +326,7 @@ export const layer = Layer.effect(
 
       const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
       const base64 = Buffer.from(buffer).toString("base64")
-      const mime = AppFileSystem.mimeType(shortest)
+      const mime = FSUtil.mimeType(shortest)
       const url = `data:${mime};base64,${base64}`
       yield* update({ projectID: input.id, icon: { url } }).pipe(
         Effect.catchTag("Project.NotFoundError", () => Effect.void),
@@ -468,7 +468,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(ProjectV2.defaultLayer),
   Layer.provide(AppProcess.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Database.defaultLayer),
   Layer.provide(RuntimeFlags.defaultLayer),
 )

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

@@ -1,7 +1,7 @@
 import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import { InstanceState } from "@/effect/instance-state"
-import { FileWatcher } from "@/file/watcher"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
 import { Git } from "@/git"
 import * as Log from "@opencode-ai/core/util/log"
 import { EventV2Bridge } from "@/event-v2-bridge"
@@ -328,9 +328,9 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
         log.info("initialized", { branch: value.current, default_branch: value.root?.name })
 
         const unsubscribe = yield* events.listen((event) => {
-          if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory)
+          if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory)
             return Effect.void
-          const data = event.data as EventV2.Data<typeof FileWatcher.Event.Updated>
+          const data = event.data as EventV2.Data<typeof Watcher.Event.Updated>
           if (!data.file.endsWith("HEAD")) return Effect.void
           return Effect.gen(function* () {
             const next = yield* get()

+ 3 - 3
packages/opencode/src/provider/provider.ts

@@ -21,7 +21,7 @@ import { Effect, Layer, Context, Schema, Types } from "effect"
 import { EffectBridge } from "@/effect/bridge"
 import { InstanceState } from "@/effect/instance-state"
 import { EffectPromise } from "@/effect/promise"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { isRecord } from "@/util/record"
 import { optionalOmitUndefined } from "@opencode-ai/core/schema"
 import { ProviderTransform } from "./transform"
@@ -1183,7 +1183,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelI
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const config = yield* Config.Service
     const auth = yield* Auth.Service
     const env = yield* Env.Service
@@ -1861,7 +1861,7 @@ export const layer = Layer.effect(
 
 export const defaultLayer = Layer.suspend(() =>
   layer.pipe(
-    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(FSUtil.defaultLayer),
     Layer.provide(Env.defaultLayer),
     Layer.provide(Config.defaultLayer),
     Layer.provide(Auth.defaultLayer),

+ 3 - 3
packages/opencode/src/reference/reference.ts

@@ -1,6 +1,6 @@
 import path from "path"
 import { Effect, Context, Layer, Scope } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { Config } from "@/config/config"
 import { ConfigReference } from "@/config/reference"
@@ -83,11 +83,11 @@ function branchLabel(branch: string | undefined) {
 
 function normalizedTarget(target?: string) {
   if (!target) return
-  return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
+  return process.platform === "win32" ? FSUtil.normalizePath(target) : target
 }
 
 function containsReferencePath(referencePath: string, target: string) {
-  return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
+  return FSUtil.contains(normalizedTarget(referencePath) ?? referencePath, target)
 }
 
 function uniqueGitReferences(references: Resolved[]) {

+ 5 - 5
packages/opencode/src/reference/repository-cache.ts

@@ -1,6 +1,6 @@
 import path from "path"
 import { Context, Effect, Layer, Schema } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Flock } from "@opencode-ai/core/util/flock"
 import { Git } from "@/git"
 import {
@@ -168,7 +168,7 @@ export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(functi
 const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(function* (
   input: EnsureInput,
   services: {
-    fs: AppFileSystem.Interface
+    fs: FSUtil.Interface
     git: Git.Interface
   },
 ) {
@@ -298,10 +298,10 @@ const ensureWithServices = Effect.fn("RepositoryCache.ensureWithServices")(funct
   )
 })
 
-export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service> = Layer.effect(
+export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service> = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const git = yield* Git.Service
 
     return Service.of({
@@ -313,7 +313,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
 )
 
 export const defaultLayer: Layer.Layer<Service> = layer.pipe(
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Git.defaultLayer),
 )
 

+ 47 - 5
packages/opencode/src/server/routes/instance/httpapi/groups/file.ts

@@ -1,5 +1,6 @@
-import { File } from "@/file"
-import { Ripgrep } from "@/file/ripgrep"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
+import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { LSP } from "@/lsp/lsp"
 import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -37,6 +38,47 @@ export const FindSymbolQuery = Schema.Struct({
   query: Schema.String,
 })
 
+export const LegacyEntry = Schema.Struct({
+  name: Schema.String,
+  path: Schema.String,
+  absolute: Schema.String,
+  type: Schema.Literals(["file", "directory"]),
+  ignored: Schema.Boolean,
+}).annotate({ identifier: "FileNode" })
+
+export const LegacyContent = Schema.Struct({
+  type: Schema.Literals(["text", "binary"]),
+  content: Schema.String,
+  diff: Schema.optional(Schema.String),
+  patch: Schema.optional(
+    Schema.Struct({
+      oldFileName: Schema.String,
+      newFileName: Schema.String,
+      oldHeader: Schema.optional(Schema.String),
+      newHeader: Schema.optional(Schema.String),
+      hunks: Schema.Array(
+        Schema.Struct({
+          oldStart: NonNegativeInt,
+          oldLines: NonNegativeInt,
+          newStart: NonNegativeInt,
+          newLines: NonNegativeInt,
+          lines: Schema.Array(Schema.String),
+        }),
+      ),
+      index: Schema.optional(Schema.String),
+    }),
+  ),
+  encoding: Schema.optional(Schema.Literal("base64")),
+  mimeType: Schema.optional(Schema.String),
+}).annotate({ identifier: "FileContent" })
+
+export const LegacyStatus = Schema.Struct({
+  path: Schema.String,
+  added: NonNegativeInt,
+  removed: NonNegativeInt,
+  status: Schema.Literals(["added", "deleted", "modified"]),
+}).annotate({ identifier: "File" })
+
 export const FilePaths = {
   findText: "/find",
   findFile: "/find/file",
@@ -82,7 +124,7 @@ export const FileApi = HttpApi.make("file")
         ),
         HttpApiEndpoint.get("list", FilePaths.list, {
           query: FileQuery,
-          success: described(Schema.Array(File.Node), "Files and directories"),
+          success: described(Schema.Array(LegacyEntry), "Files and directories"),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "file.list",
@@ -92,7 +134,7 @@ export const FileApi = HttpApi.make("file")
         ),
         HttpApiEndpoint.get("content", FilePaths.content, {
           query: FileQuery,
-          success: described(File.Content, "File content"),
+          success: described(LegacyContent, "File content"),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "file.read",
@@ -102,7 +144,7 @@ export const FileApi = HttpApi.make("file")
         ),
         HttpApiEndpoint.get("status", FilePaths.status, {
           query: WorkspaceRoutingQuery,
-          success: described(Schema.Array(File.Info), "File status"),
+          success: described(Schema.Array(LegacyStatus), "File status"),
         }).annotateMerge(
           OpenApi.annotations({
             identifier: "file.status",

+ 3 - 3
packages/opencode/src/server/routes/instance/httpapi/groups/v2/fs.ts

@@ -1,4 +1,4 @@
-import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
+import { FileSystem } from "@opencode-ai/core/filesystem"
 import { RelativePath } from "@opencode-ai/core/schema"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -21,7 +21,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
   .add(
     HttpApiEndpoint.get("read", "/api/fs/read", {
       query: ReadQuery,
-      success: LocationFileSystem.Content,
+      success: FileSystem.Content,
     })
       .annotateMerge(locationQueryOpenApi)
       .annotateMerge(
@@ -35,7 +35,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
   .add(
     HttpApiEndpoint.get("list", "/api/fs/list", {
       query: ListQuery,
-      success: Schema.Array(LocationFileSystem.Entry),
+      success: Schema.Array(FileSystem.Entry),
     })
       .annotateMerge(locationQueryOpenApi)
       .annotateMerge(

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

@@ -1,7 +1,7 @@
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Location } from "@opencode-ai/core/location"
 import { LocationServiceMap } from "@opencode-ai/core/location-layer"
-import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
+import { FileSystem } from "@opencode-ai/core/filesystem"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { ProjectReference } from "@opencode-ai/core/project-reference"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -42,7 +42,7 @@ export class V2LocationMiddleware extends HttpApiMiddleware.Service<
       | PluginBoot.Service
       | PermissionV2.Service
       | ProjectReference.Service
-      | LocationFileSystem.Service
+      | FileSystem.Service
   }
 >()("@opencode/ExperimentalHttpApiV2Location") {}
 

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

@@ -1,14 +1,24 @@
 import * as InstanceState from "@/effect/instance-state"
-import { File } from "@/file"
-import { Ripgrep } from "@/file/ripgrep"
-import { Effect } from "effect"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { LocationServiceMap } from "@opencode-ai/core/location-layer"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
+import { Effect, Layer } from "effect"
+import path from "path"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../api"
 
 export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
   Effect.gen(function* () {
-    const svc = yield* File.Service
     const ripgrep = yield* Ripgrep.Service
+    const locations = yield* LocationServiceMap
+
+    const filesystem = Effect.fnUntraced(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
+      return yield* effect.pipe(
+        Effect.provide(locations.get({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })),
+      )
+    })
 
     const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
       return (yield* ripgrep
@@ -19,12 +29,15 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
     const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
       query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
     }) {
-      return yield* svc.search({
-        query: ctx.query.query,
-        limit: ctx.query.limit ?? 10,
-        dirs: ctx.query.dirs !== "false",
-        type: ctx.query.type,
-      })
+      return (yield* filesystem(
+        FileSystem.Service.use((fs) =>
+          fs.find({
+            query: ctx.query.query,
+            limit: ctx.query.limit ?? 10,
+            type: ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined),
+          }),
+        ),
+      )).map((item) => item.path)
     })
 
     const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
@@ -32,15 +45,42 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
     })
 
     const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
-      return yield* svc.list(ctx.query.path)
+      const directory = (yield* InstanceState.context).directory
+      return yield* filesystem(
+        FileSystem.Service.use((fs) =>
+          fs.list({ path: RelativePath.make(ctx.query.path) }).pipe(
+            Effect.map((items) =>
+              items.map((item) => ({
+                name: path.basename(item.path),
+                path: item.path,
+                absolute: path.join(directory, item.path),
+                type: item.type,
+                ignored: fs.isIgnored(item.path, item.type),
+              })),
+            ),
+          ),
+        ),
+      )
     })
 
     const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
-      return yield* svc.read(ctx.query.path)
+      const directory = (yield* InstanceState.context).directory
+      const file = path.resolve(directory, ctx.query.path)
+      if (!FSUtil.contains(directory, file)) return yield* Effect.die(new Error("Path escapes the location"))
+      if (!(yield* FSUtil.Service.use((fs) => fs.existsSafe(file)))) return { type: "text" as const, content: "" }
+      return yield* filesystem(
+        FileSystem.Service.use((fs) => fs.read({ path: RelativePath.make(ctx.query.path) })),
+      ).pipe(
+        Effect.map((item) => ({
+          type: item.type,
+          content: item.type === "text" ? item.content.trim() : item.content,
+          ...(item.type === "binary" ? { encoding: item.encoding, mimeType: item.mime } : {}),
+        })),
+      )
     })
 
     const status = Effect.fn("FileHttpApi.status")(function* () {
-      return yield* svc.status()
+      return []
     })
 
     return handlers
@@ -51,4 +91,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
       .handle("content", content)
       .handle("status", status)
   }),
-)
+).pipe(Layer.provide(LocationServiceMap.layer))

+ 3 - 3
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/fs.ts

@@ -1,4 +1,4 @@
-import { LocationFileSystem } from "@opencode-ai/core/location-filesystem"
+import { FileSystem } from "@opencode-ai/core/filesystem"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../../api"
@@ -6,7 +6,7 @@ import { InstanceHttpApi } from "../../api"
 export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
   Effect.gen(function* () {
     return handlers
-      .handle("read", (ctx) => LocationFileSystem.Service.use((fs) => fs.read(ctx.query)))
-      .handle("list", (ctx) => LocationFileSystem.Service.use((fs) => fs.list(ctx.query)))
+      .handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
+      .handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
   }),
 )

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

@@ -9,16 +9,14 @@ import {
   HttpServerResponse,
 } from "effect/unstable/http"
 import * as Socket from "effect/unstable/socket/Socket"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Account } from "@/account/account"
 import { Agent } from "@/agent/agent"
 import { Auth } from "@/auth"
 import { Config } from "@/config/config"
 import { Command } from "@/command"
 import * as Observability from "@opencode-ai/core/effect/observability"
-import { File } from "@/file"
-import { FileWatcher } from "@/file/watcher"
-import { Ripgrep } from "@/file/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { Format } from "@/format"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { LSP } from "@/lsp/lsp"
@@ -166,7 +164,7 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe
 
 const uiRoute = HttpRouter.use((router) =>
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const client = yield* HttpClient.HttpClient
     const flags = yield* RuntimeFlags.Service
     yield* router.add("*", "/*", (request) =>
@@ -198,8 +196,6 @@ export function createRoutes(
       Auth.defaultLayer,
       Command.defaultLayer,
       Config.defaultLayer,
-      File.defaultLayer,
-      FileWatcher.defaultLayer,
       Format.defaultLayer,
       LSP.defaultLayer,
       Installation.defaultLayer,
@@ -232,7 +228,7 @@ export function createRoutes(
       Vcs.defaultLayer,
       Workspace.defaultLayer,
       Worktree.appLayer,
-      AppFileSystem.defaultLayer,
+      FSUtil.defaultLayer,
       FetchHttpClient.layer,
       HttpServer.layerServices,
     ]),

+ 4 - 4
packages/opencode/src/server/shared/ui.ts

@@ -1,4 +1,4 @@
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Effect, Stream } from "effect"
 import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { createHash } from "node:crypto"
@@ -53,7 +53,7 @@ function notFound() {
 }
 
 function embeddedUIResponse(file: string, body: Uint8Array) {
-  const mime = AppFileSystem.mimeType(file)
+  const mime = FSUtil.mimeType(file)
   const headers = new Headers({ "content-type": mime })
   if (mime.startsWith("text/html")) {
     headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
@@ -63,7 +63,7 @@ function embeddedUIResponse(file: string, body: Uint8Array) {
 
 export function serveEmbeddedUIEffect(
   requestPath: string,
-  fs: AppFileSystem.Interface,
+  fs: FSUtil.Interface,
   embeddedWebUI: Record<string, string>,
 ) {
   const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
@@ -77,7 +77,7 @@ export function serveEmbeddedUIEffect(
 
 export function serveUIEffect(
   request: HttpServerRequest.HttpServerRequest,
-  services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
+  services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
 ) {
   return Effect.gen(function* () {
     const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))

+ 8 - 8
packages/opencode/src/session/instruction.ts

@@ -6,7 +6,7 @@ import { Config } from "@/config/config"
 import { InstanceState } from "@/effect/instance-state"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { withTransientReadRetry } from "@/util/effect-http-client"
 import { Global } from "@opencode-ai/core/global"
 import type { MessageV2 } from "./message-v2"
@@ -31,14 +31,14 @@ function extract(messages: SessionLegacy.WithParts[]) {
 
 export interface Interface {
   readonly clear: (messageID: MessageID) => Effect.Effect<void>
-  readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
-  readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
-  readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
+  readonly systemPaths: () => Effect.Effect<Set<string>, FSUtil.Error>
+  readonly system: () => Effect.Effect<string[], FSUtil.Error>
+  readonly find: (dir: string) => Effect.Effect<string | undefined, FSUtil.Error>
   readonly resolve: (
     messages: SessionLegacy.WithParts[],
     filepath: string,
     messageID: MessageID,
-  ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
+  ) => Effect.Effect<{ filepath: string; content: string }[], FSUtil.Error>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
@@ -46,12 +46,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/In
 export const layer: Layer.Layer<
   Service,
   never,
-  AppFileSystem.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
+  FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service
 > = Layer.effect(
   Service,
   Effect.gen(function* () {
     const cfg = yield* Config.Service
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const global = yield* Global.Service
     const flags = yield* RuntimeFlags.Service
     const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
@@ -225,7 +225,7 @@ export const layer: Layer.Layer<
 export const defaultLayer = layer.pipe(
   Layer.provide(Config.defaultLayer),
   Layer.provide(Global.layer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(FetchHttpClient.layer),
   Layer.provide(RuntimeFlags.defaultLayer),
 )

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

@@ -37,7 +37,7 @@ import { SessionStatus } from "./status"
 import { LLM } from "./llm"
 import { Shell } from "@/shell/shell"
 import { ShellID } from "@/tool/shell/id"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Truncate } from "@/tool/truncate"
 import { Image } from "@/image/image"
 import { decodeDataUrl } from "@/util/data-url"
@@ -112,7 +112,7 @@ export const layer = Layer.effect(
     const commands = yield* Command.Service
     const config = yield* Config.Service
     const permission = yield* Permission.Service
-    const fsys = yield* AppFileSystem.Service
+    const fsys = yield* FSUtil.Service
     const mcp = yield* MCP.Service
     const lsp = yield* LSP.Service
     const registry = yield* ToolRegistry.Service
@@ -178,7 +178,7 @@ export const layer = Layer.effect(
 
             const target = name.slice(slash + 1)
             const targetPath = path.resolve(reference.path, target)
-            if (!AppFileSystem.contains(reference.path, targetPath)) {
+            if (!FSUtil.contains(reference.path, targetPath)) {
               parts.push(
                 referenceTextPart({
                   reference,
@@ -777,7 +777,7 @@ export const layer = Layer.effect(
 
         const reference = yield* references.get(name.slice(0, slash))
         if (!reference || reference.kind === "invalid") return
-        if (!AppFileSystem.contains(reference.path, filepath)) return
+        if (!FSUtil.contains(reference.path, filepath)) return
 
         const target = path.relative(reference.path, filepath).split(path.sep).join("/")
         if (!target || target.startsWith("../") || target === "..") return
@@ -1343,7 +1343,7 @@ export const layer = Layer.effect(
           const isLastStep = step >= maxSteps
           msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
             Effect.provideService(RuntimeFlags.Service, flags),
-            Effect.provideService(AppFileSystem.Service, fsys),
+            Effect.provideService(FSUtil.Service, fsys),
             Effect.provideService(Session.Service, sessions),
           )
 
@@ -1656,7 +1656,7 @@ export const defaultLayer = Layer.suspend(() =>
     Layer.provide(Provider.defaultLayer),
     Layer.provide(Config.defaultLayer),
     Layer.provide(Instruction.defaultLayer),
-    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(FSUtil.defaultLayer),
     Layer.provide(Plugin.defaultLayer),
     Layer.provide(Session.defaultLayer),
     Layer.provide(SessionRevert.defaultLayer),

+ 2 - 2
packages/opencode/src/session/reminders.ts

@@ -2,7 +2,7 @@ import path from "path"
 import { SessionLegacy } from "@opencode-ai/core/session/legacy"
 import { Effect } from "effect"
 import { Agent } from "@/agent/agent"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { InstanceState } from "@/effect/instance-state"
 import { RuntimeFlags } from "@/effect/runtime-flags"
 import { PartID } from "./schema"
@@ -18,7 +18,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
   session: Session.Info
 }) {
   const flags = yield* RuntimeFlags.Service
-  const fsys = yield* AppFileSystem.Service
+  const fsys = yield* FSUtil.Service
   const sessions = yield* Session.Service
   const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
   if (!userMessage) return input.messages

+ 1 - 1
packages/opencode/src/shell/shell.ts

@@ -1,7 +1,7 @@
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { lazy } from "@/util/lazy"
 import { Filesystem } from "@/util/filesystem"
-import { which } from "@/util/which"
+import { which } from "@opencode-ai/core/util/which"
 import path from "path"
 import { spawn, type ChildProcess } from "child_process"
 import { setTimeout as sleep } from "node:timers/promises"

+ 80 - 81
packages/opencode/src/skill/discovery.ts

@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
 import { Effect, Layer, Path, Schema, Context } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import { withTransientReadRetry } from "@/util/effect-http-client"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import * as Log from "@opencode-ai/core/util/log"
 
@@ -24,92 +24,91 @@ export interface Interface {
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
 
-export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
-  Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const log = Log.create({ service: "skill-discovery" })
-      const fs = yield* AppFileSystem.Service
-      const path = yield* Path.Path
-      const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
-      const cache = path.join(Global.Path.cache, "skills")
-
-      const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
-        if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
-
-        return yield* HttpClientRequest.get(url).pipe(
-          http.execute,
-          Effect.flatMap((res) => res.arrayBuffer),
-          Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
-          Effect.as(true),
-          Effect.catch((err) =>
-            Effect.sync(() => {
-              log.error("failed to download", { url, err })
-              return false
-            }),
-          ),
-        )
-      })
-
-      const pull = Effect.fn("Discovery.pull")(function* (url: string) {
-        const base = url.endsWith("/") ? url : `${url}/`
-        const index = new URL("index.json", base).href
-        const host = base.slice(0, -1)
-
-        log.info("fetching index", { url: index })
-
-        const data = yield* HttpClientRequest.get(index).pipe(
-          HttpClientRequest.acceptJson,
-          http.execute,
-          Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
-          Effect.catch((err) =>
-            Effect.sync(() => {
-              log.error("failed to fetch index", { url: index, err })
-              return null
-            }),
-          ),
-        )
-
-        if (!data) return []
-
-        const list = data.skills.filter((skill) => {
-          if (!skill.files.includes("SKILL.md")) {
-            log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
+export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const log = Log.create({ service: "skill-discovery" })
+    const fs = yield* FSUtil.Service
+    const path = yield* Path.Path
+    const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
+    const cache = path.join(Global.Path.cache, "skills")
+
+    const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
+      if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
+
+      return yield* HttpClientRequest.get(url).pipe(
+        http.execute,
+        Effect.flatMap((res) => res.arrayBuffer),
+        Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
+        Effect.as(true),
+        Effect.catch((err) =>
+          Effect.sync(() => {
+            log.error("failed to download", { url, err })
             return false
-          }
-          return true
-        })
-
-        const dirs = yield* Effect.forEach(
-          list,
-          (skill) =>
-            Effect.gen(function* () {
-              const root = path.join(cache, skill.name)
-
-              yield* Effect.forEach(
-                skill.files,
-                (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
-                {
-                  concurrency: fileConcurrency,
-                },
-              )
-
-              const md = path.join(root, "SKILL.md")
-              return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
-            }),
-          { concurrency: skillConcurrency },
-        )
-
-        return dirs.filter((dir): dir is string => dir !== null)
+          }),
+        ),
+      )
+    })
+
+    const pull = Effect.fn("Discovery.pull")(function* (url: string) {
+      const base = url.endsWith("/") ? url : `${url}/`
+      const index = new URL("index.json", base).href
+      const host = base.slice(0, -1)
+
+      log.info("fetching index", { url: index })
+
+      const data = yield* HttpClientRequest.get(index).pipe(
+        HttpClientRequest.acceptJson,
+        http.execute,
+        Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
+        Effect.catch((err) =>
+          Effect.sync(() => {
+            log.error("failed to fetch index", { url: index, err })
+            return null
+          }),
+        ),
+      )
+
+      if (!data) return []
+
+      const list = data.skills.filter((skill) => {
+        if (!skill.files.includes("SKILL.md")) {
+          log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
+          return false
+        }
+        return true
       })
 
-      return Service.of({ pull })
-    }),
-  )
+      const dirs = yield* Effect.forEach(
+        list,
+        (skill) =>
+          Effect.gen(function* () {
+            const root = path.join(cache, skill.name)
+
+            yield* Effect.forEach(
+              skill.files,
+              (file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
+              {
+                concurrency: fileConcurrency,
+              },
+            )
+
+            const md = path.join(root, "SKILL.md")
+            return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
+          }),
+        { concurrency: skillConcurrency },
+      )
+
+      return dirs.filter((dir): dir is string => dir !== null)
+    })
+
+    return Service.of({ pull })
+  }),
+)
 
 export const defaultLayer: Layer.Layer<Service> = layer.pipe(
   Layer.provide(FetchHttpClient.layer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(NodePath.layer),
 )
 

+ 4 - 4
packages/opencode/src/skill/index.ts

@@ -7,7 +7,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
 import { InstanceState } from "@/effect/instance-state"
 import { Global } from "@opencode-ai/core/global"
 import { Permission } from "@/permission"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Config } from "@/config/config"
 import { ConfigMarkdown } from "@/config/markdown"
 import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -173,7 +173,7 @@ const scan = Effect.fnUntraced(function* (
 const discoverSkills = Effect.fnUntraced(function* (
   config: Config.Interface,
   discovery: Discovery.Interface,
-  fsys: AppFileSystem.Interface,
+  fsys: FSUtil.Interface,
   global: Global.Interface,
   disableExternalSkills: boolean,
   disableClaudeCodeSkills: boolean,
@@ -253,7 +253,7 @@ export const layer = Layer.effect(
     const discovery = yield* Discovery.Service
     const config = yield* Config.Service
     const events = yield* EventV2Bridge.Service
-    const fsys = yield* AppFileSystem.Service
+    const fsys = yield* FSUtil.Service
     const global = yield* Global.Service
     const flags = yield* RuntimeFlags.Service
     const discovered = yield* InstanceState.make(
@@ -322,7 +322,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Discovery.defaultLayer),
   Layer.provide(Config.defaultLayer),
   Layer.provide(EventV2Bridge.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Global.layer),
   Layer.provide(RuntimeFlags.defaultLayer),
 )

+ 624 - 627
packages/opencode/src/snapshot/index.ts

@@ -4,7 +4,7 @@ import { formatPatch, structuredPatch } from "diff"
 import path from "path"
 import { AppProcess } from "@opencode-ai/core/process"
 import { InstanceState } from "@/effect/instance-state"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Hash } from "@opencode-ai/core/util/hash"
 import { Config } from "@/config/config"
 import { Global } from "@opencode-ai/core/global"
@@ -55,377 +55,404 @@ export interface Interface {
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
 
-export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProcess.Service | Config.Service> =
-  Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const fs = yield* AppFileSystem.Service
-      const appProcess = yield* AppProcess.Service
-      const config = yield* Config.Service
-      const locks = new Map<string, Semaphore.Semaphore>()
-
-      const lock = (key: string) => {
-        const hit = locks.get(key)
-        if (hit) return hit
-
-        const next = Semaphore.makeUnsafe(1)
-        locks.set(key, next)
-        return next
-      }
-
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("Snapshot.state")(function* (ctx) {
-          const state = {
-            directory: ctx.directory,
-            worktree: ctx.worktree,
-            gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
-            vcs: ctx.project.vcs,
-          }
-
-          const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
-
-          const feed = (list: string[]) => list.join("\0") + "\0"
-
-          const git = Effect.fnUntraced(
-            function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
-              const result = yield* appProcess.run(
-                ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }),
-                { stdin: opts?.stdin },
-              )
-              return {
-                code: ChildProcessSpawner.ExitCode(result.exitCode),
-                text: result.stdout.toString("utf8"),
-                stderr: result.stderr.toString("utf8"),
-              } satisfies GitResult
+export const layer: Layer.Layer<Service, never, FSUtil.Service | AppProcess.Service | Config.Service> = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const fs = yield* FSUtil.Service
+    const appProcess = yield* AppProcess.Service
+    const config = yield* Config.Service
+    const locks = new Map<string, Semaphore.Semaphore>()
+
+    const lock = (key: string) => {
+      const hit = locks.get(key)
+      if (hit) return hit
+
+      const next = Semaphore.makeUnsafe(1)
+      locks.set(key, next)
+      return next
+    }
+
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("Snapshot.state")(function* (ctx) {
+        const state = {
+          directory: ctx.directory,
+          worktree: ctx.worktree,
+          gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
+          vcs: ctx.project.vcs,
+        }
+
+        const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
+
+        const feed = (list: string[]) => list.join("\0") + "\0"
+
+        const git = Effect.fnUntraced(
+          function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string>; stdin?: string }) {
+            const result = yield* appProcess.run(
+              ChildProcess.make("git", cmd, { cwd: opts?.cwd, env: opts?.env, extendEnv: true }),
+              { stdin: opts?.stdin },
+            )
+            return {
+              code: ChildProcessSpawner.ExitCode(result.exitCode),
+              text: result.stdout.toString("utf8"),
+              stderr: result.stderr.toString("utf8"),
+            } satisfies GitResult
+          },
+          Effect.catch((err) =>
+            Effect.succeed({
+              code: ChildProcessSpawner.ExitCode(1),
+              text: "",
+              stderr: err instanceof Error ? err.message : String(err),
+            }),
+          ),
+        )
+
+        const ignore = Effect.fnUntraced(function* (files: string[]) {
+          if (!files.length) return new Set<string>()
+          const check = yield* git(
+            [
+              ...quote,
+              "--git-dir",
+              path.join(state.worktree, ".git"),
+              "--work-tree",
+              state.worktree,
+              "check-ignore",
+              "--no-index",
+              "--stdin",
+              "-z",
+            ],
+            {
+              cwd: state.directory,
+              stdin: feed(files),
             },
-            Effect.catch((err) =>
-              Effect.succeed({
-                code: ChildProcessSpawner.ExitCode(1),
-                text: "",
-                stderr: err instanceof Error ? err.message : String(err),
-              }),
-            ),
           )
-
-          const ignore = Effect.fnUntraced(function* (files: string[]) {
-            if (!files.length) return new Set<string>()
-            const check = yield* git(
-              [
-                ...quote,
-                "--git-dir",
-                path.join(state.worktree, ".git"),
-                "--work-tree",
-                state.worktree,
-                "check-ignore",
-                "--no-index",
-                "--stdin",
-                "-z",
-              ],
-              {
-                cwd: state.directory,
-                stdin: feed(files),
-              },
-            )
-            if (check.code !== 0 && check.code !== 1) return new Set<string>()
-            return new Set(check.text.split("\0").filter(Boolean))
+          if (check.code !== 0 && check.code !== 1) return new Set<string>()
+          return new Set(check.text.split("\0").filter(Boolean))
+        })
+
+        const drop = Effect.fnUntraced(function* (files: string[]) {
+          if (!files.length) return
+          yield* git(
+            [
+              ...cfg,
+              ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
+            ],
+            {
+              cwd: state.directory,
+              stdin: feed(files),
+            },
+          )
+        })
+
+        const stage = Effect.fnUntraced(function* (files: string[]) {
+          if (!files.length) return
+          const result = yield* git(
+            [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
+            {
+              cwd: state.directory,
+              stdin: feed(files),
+            },
+          )
+          if (result.code === 0) return
+          log.warn("failed to add snapshot files", {
+            exitCode: result.code,
+            stderr: result.stderr,
           })
+        })
 
-          const drop = Effect.fnUntraced(function* (files: string[]) {
-            if (!files.length) return
-            yield* git(
-              [
-                ...cfg,
-                ...args(["rm", "--cached", "-f", "--ignore-unmatch", "--pathspec-from-file=-", "--pathspec-file-nul"]),
-              ],
-              {
-                cwd: state.directory,
-                stdin: feed(files),
-              },
-            )
-          })
+        const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+        const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
+        const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
+        const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
+
+        const enabled = Effect.fnUntraced(function* () {
+          if (state.vcs !== "git") return false
+          return (yield* config.get()).snapshot !== false
+        })
 
-          const stage = Effect.fnUntraced(function* (files: string[]) {
-            if (!files.length) return
-            const result = yield* git(
-              [...cfg, ...args(["add", "--all", "--sparse", "--pathspec-from-file=-", "--pathspec-file-nul"])],
-              {
+        const excludes = Effect.fnUntraced(function* () {
+          const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
+            cwd: state.worktree,
+          })
+          const file = result.text.trim()
+          if (!file) return
+          if (!(yield* exists(file))) return
+          return file
+        })
+
+        const sync = Effect.fnUntraced(function* (list: string[] = []) {
+          const file = yield* excludes()
+          const target = path.join(state.gitdir, "info", "exclude")
+          const text = [
+            file ? (yield* read(file)).trimEnd() : "",
+            ...list.map((item) => `/${item.replaceAll("\\", "/")}`),
+          ]
+            .filter(Boolean)
+            .join("\n")
+          yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
+          yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
+        })
+
+        const add = Effect.fnUntraced(function* () {
+          yield* sync()
+          const [diff, other] = yield* Effect.all(
+            [
+              git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
                 cwd: state.directory,
-                stdin: feed(files),
-              },
-            )
-            if (result.code === 0) return
-            log.warn("failed to add snapshot files", {
-              exitCode: result.code,
-              stderr: result.stderr,
+              }),
+              git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
+                cwd: state.directory,
+              }),
+            ],
+            { concurrency: 2 },
+          )
+          if (diff.code !== 0 || other.code !== 0) {
+            log.warn("failed to list snapshot files", {
+              diffCode: diff.code,
+              diffStderr: diff.stderr,
+              otherCode: other.code,
+              otherStderr: other.stderr,
             })
-          })
+            return
+          }
 
-          const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
-          const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
-          const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
-          const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
+          const tracked = diff.text.split("\0").filter(Boolean)
+          const untracked = other.text.split("\0").filter(Boolean)
+          const all = Array.from(new Set([...tracked, ...untracked]))
+          if (!all.length) return
 
-          const enabled = Effect.fnUntraced(function* () {
-            if (state.vcs !== "git") return false
-            return (yield* config.get()).snapshot !== false
-          })
+          // Resolve source-repo ignore rules against the exact candidate set.
+          // --no-index keeps this pattern-based even when a path is already tracked.
+          const ignored = yield* ignore(all)
 
-          const excludes = Effect.fnUntraced(function* () {
-            const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
-              cwd: state.worktree,
-            })
-            const file = result.text.trim()
-            if (!file) return
-            if (!(yield* exists(file))) return
-            return file
-          })
-
-          const sync = Effect.fnUntraced(function* (list: string[] = []) {
-            const file = yield* excludes()
-            const target = path.join(state.gitdir, "info", "exclude")
-            const text = [
-              file ? (yield* read(file)).trimEnd() : "",
-              ...list.map((item) => `/${item.replaceAll("\\", "/")}`),
-            ]
-              .filter(Boolean)
-              .join("\n")
-            yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
-            yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
-          })
+          // Remove newly-ignored files from snapshot index to prevent re-adding
+          if (ignored.size > 0) {
+            const ignoredFiles = Array.from(ignored)
+            log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
+            yield* drop(ignoredFiles)
+          }
 
-          const add = Effect.fnUntraced(function* () {
-            yield* sync()
-            const [diff, other] = yield* Effect.all(
-              [
-                git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
-                  cwd: state.directory,
-                }),
-                git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
+          const allow = all.filter((item) => !ignored.has(item))
+          if (!allow.length) return
+
+          const large = new Set(
+            (yield* Effect.all(
+              allow.map((item) =>
+                fs
+                  .stat(path.join(state.directory, item))
+                  .pipe(Effect.catch(() => Effect.void))
+                  .pipe(
+                    Effect.map((stat) => {
+                      if (!stat || stat.type !== "File") return
+                      const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
+                      return size > limit ? item : undefined
+                    }),
+                  ),
+              ),
+              { concurrency: 8 },
+            )).filter((item): item is string => Boolean(item)),
+          )
+          const block = new Set(untracked.filter((item) => large.has(item)))
+          yield* sync(Array.from(block))
+          // Stage only the allowed candidate paths so snapshot updates stay scoped.
+          yield* stage(allow.filter((item) => !block.has(item)))
+        })
+
+        const cleanup = Effect.fnUntraced(function* () {
+          return yield* locked(
+            Effect.gen(function* () {
+              if (!(yield* enabled())) return
+              if (!(yield* exists(state.gitdir))) return
+              const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
+              if (result.code !== 0) {
+                log.warn("cleanup failed", {
+                  exitCode: result.code,
+                  stderr: result.stderr,
+                })
+                return
+              }
+              log.info("cleanup", { prune })
+            }),
+          )
+        })
+
+        const track = Effect.fnUntraced(function* () {
+          return yield* locked(
+            Effect.gen(function* () {
+              if (!(yield* enabled())) return
+              const existed = yield* exists(state.gitdir)
+              yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
+              if (!existed) {
+                yield* git(["init"], {
+                  env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
+                })
+                yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
+                yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
+                yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
+                yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
+                log.info("initialized")
+              }
+              yield* add()
+              const result = yield* git(args(["write-tree"]), { cwd: state.directory })
+              const hash = result.text.trim()
+              log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
+              return hash
+            }),
+          )
+        })
+
+        const patch = Effect.fnUntraced(function* (hash: string) {
+          return yield* locked(
+            Effect.gen(function* () {
+              yield* add()
+              const result = yield* git(
+                [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
+                {
                   cwd: state.directory,
-                }),
-              ],
-              { concurrency: 2 },
-            )
-            if (diff.code !== 0 || other.code !== 0) {
-              log.warn("failed to list snapshot files", {
-                diffCode: diff.code,
-                diffStderr: diff.stderr,
-                otherCode: other.code,
-                otherStderr: other.stderr,
-              })
-              return
-            }
-
-            const tracked = diff.text.split("\0").filter(Boolean)
-            const untracked = other.text.split("\0").filter(Boolean)
-            const all = Array.from(new Set([...tracked, ...untracked]))
-            if (!all.length) return
-
-            // Resolve source-repo ignore rules against the exact candidate set.
-            // --no-index keeps this pattern-based even when a path is already tracked.
-            const ignored = yield* ignore(all)
-
-            // Remove newly-ignored files from snapshot index to prevent re-adding
-            if (ignored.size > 0) {
-              const ignoredFiles = Array.from(ignored)
-              log.info("removing gitignored files from snapshot", { count: ignoredFiles.length })
-              yield* drop(ignoredFiles)
-            }
-
-            const allow = all.filter((item) => !ignored.has(item))
-            if (!allow.length) return
-
-            const large = new Set(
-              (yield* Effect.all(
-                allow.map((item) =>
-                  fs
-                    .stat(path.join(state.directory, item))
-                    .pipe(Effect.catch(() => Effect.void))
-                    .pipe(
-                      Effect.map((stat) => {
-                        if (!stat || stat.type !== "File") return
-                        const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
-                        return size > limit ? item : undefined
-                      }),
-                    ),
-                ),
-                { concurrency: 8 },
-              )).filter((item): item is string => Boolean(item)),
-            )
-            const block = new Set(untracked.filter((item) => large.has(item)))
-            yield* sync(Array.from(block))
-            // Stage only the allowed candidate paths so snapshot updates stay scoped.
-            yield* stage(allow.filter((item) => !block.has(item)))
-          })
+                },
+              )
+              if (result.code !== 0) {
+                log.warn("failed to get diff", { hash, exitCode: result.code })
+                return { hash, files: [] }
+              }
+              const files = result.text
+                .trim()
+                .split("\n")
+                .map((x) => x.trim())
+                .filter(Boolean)
+
+              // Hide ignored-file removals from the user-facing patch output.
+              const ignored = yield* ignore(files)
 
-          const cleanup = Effect.fnUntraced(function* () {
-            return yield* locked(
-              Effect.gen(function* () {
-                if (!(yield* enabled())) return
-                if (!(yield* exists(state.gitdir))) return
-                const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
-                if (result.code !== 0) {
-                  log.warn("cleanup failed", {
-                    exitCode: result.code,
-                    stderr: result.stderr,
+              return {
+                hash,
+                files: files
+                  .filter((item) => !ignored.has(item))
+                  .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+              }
+            }),
+          )
+        })
+
+        const restore = Effect.fnUntraced(function* (snapshot: string) {
+          return yield* locked(
+            Effect.gen(function* () {
+              log.info("restore", { commit: snapshot })
+              const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
+              if (result.code === 0) {
+                const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
+                  cwd: state.worktree,
+                })
+                if (checkout.code === 0) return
+                log.error("failed to restore snapshot", {
+                  snapshot,
+                  exitCode: checkout.code,
+                  stderr: checkout.stderr,
+                })
+                return
+              }
+              log.error("failed to restore snapshot", {
+                snapshot,
+                exitCode: result.code,
+                stderr: result.stderr,
+              })
+            }),
+          )
+        })
+
+        const revert = Effect.fnUntraced(function* (patches: Patch[]) {
+          return yield* locked(
+            Effect.gen(function* () {
+              const ops: { hash: string; file: string; rel: string }[] = []
+              const seen = new Set<string>()
+              for (const item of patches) {
+                for (const file of item.files) {
+                  if (seen.has(file)) continue
+                  seen.add(file)
+                  ops.push({
+                    hash: item.hash,
+                    file,
+                    rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
                   })
-                  return
                 }
-                log.info("cleanup", { prune })
-              }),
-            )
-          })
+              }
 
-          const track = Effect.fnUntraced(function* () {
-            return yield* locked(
-              Effect.gen(function* () {
-                if (!(yield* enabled())) return
-                const existed = yield* exists(state.gitdir)
-                yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
-                if (!existed) {
-                  yield* git(["init"], {
-                    env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
-                  })
-                  yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
-                  yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
-                  yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
-                  yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
-                  log.info("initialized")
+              const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
+                log.info("reverting", { file: op.file, hash: op.hash })
+                const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
+                  cwd: state.worktree,
+                })
+                if (result.code === 0) return
+                const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
+                  cwd: state.worktree,
+                })
+                if (tree.code === 0 && tree.text.trim()) {
+                  log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
+                  return
                 }
-                yield* add()
-                const result = yield* git(args(["write-tree"]), { cwd: state.directory })
-                const hash = result.text.trim()
-                log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
-                return hash
-              }),
-            )
-          })
+                log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
+                yield* remove(op.file)
+              })
 
-          const patch = Effect.fnUntraced(function* (hash: string) {
-            return yield* locked(
-              Effect.gen(function* () {
-                yield* add()
-                const result = yield* git(
-                  [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
-                  {
-                    cwd: state.directory,
-                  },
-                )
-                if (result.code !== 0) {
-                  log.warn("failed to get diff", { hash, exitCode: result.code })
-                  return { hash, files: [] }
+              const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
+
+              for (let i = 0; i < ops.length; ) {
+                const first = ops[i]!
+                const run = [first]
+                let j = i + 1
+                // Only batch adjacent files when their paths cannot affect each other.
+                while (j < ops.length && run.length < 100) {
+                  const next = ops[j]!
+                  if (next.hash !== first.hash) break
+                  if (run.some((item) => clash(item.rel, next.rel))) break
+                  run.push(next)
+                  j += 1
                 }
-                const files = result.text
-                  .trim()
-                  .split("\n")
-                  .map((x) => x.trim())
-                  .filter(Boolean)
 
-                // Hide ignored-file removals from the user-facing patch output.
-                const ignored = yield* ignore(files)
-
-                return {
-                  hash,
-                  files: files
-                    .filter((item) => !ignored.has(item))
-                    .map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
+                if (run.length === 1) {
+                  yield* single(first)
+                  i = j
+                  continue
                 }
-              }),
-            )
-          })
 
-          const restore = Effect.fnUntraced(function* (snapshot: string) {
-            return yield* locked(
-              Effect.gen(function* () {
-                log.info("restore", { commit: snapshot })
-                const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
-                if (result.code === 0) {
-                  const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
+                const tree = yield* git(
+                  [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
+                  {
                     cwd: state.worktree,
-                  })
-                  if (checkout.code === 0) return
-                  log.error("failed to restore snapshot", {
-                    snapshot,
-                    exitCode: checkout.code,
-                    stderr: checkout.stderr,
-                  })
-                  return
-                }
-                log.error("failed to restore snapshot", {
-                  snapshot,
-                  exitCode: result.code,
-                  stderr: result.stderr,
-                })
-              }),
-            )
-          })
-
-          const revert = Effect.fnUntraced(function* (patches: Patch[]) {
-            return yield* locked(
-              Effect.gen(function* () {
-                const ops: { hash: string; file: string; rel: string }[] = []
-                const seen = new Set<string>()
-                for (const item of patches) {
-                  for (const file of item.files) {
-                    if (seen.has(file)) continue
-                    seen.add(file)
-                    ops.push({
-                      hash: item.hash,
-                      file,
-                      rel: path.relative(state.worktree, file).replaceAll("\\", "/"),
-                    })
-                  }
-                }
+                  },
+                )
 
-                const single = Effect.fnUntraced(function* (op: (typeof ops)[number]) {
-                  log.info("reverting", { file: op.file, hash: op.hash })
-                  const result = yield* git([...core, ...args(["checkout", op.hash, "--", op.file])], {
-                    cwd: state.worktree,
-                  })
-                  if (result.code === 0) return
-                  const tree = yield* git([...core, ...args(["ls-tree", op.hash, "--", op.rel])], {
-                    cwd: state.worktree,
+                if (tree.code !== 0) {
+                  log.info("batched ls-tree failed, falling back to single-file revert", {
+                    hash: first.hash,
+                    files: run.length,
                   })
-                  if (tree.code === 0 && tree.text.trim()) {
-                    log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash })
-                    return
-                  }
-                  log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
-                  yield* remove(op.file)
-                })
-
-                const clash = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
-
-                for (let i = 0; i < ops.length; ) {
-                  const first = ops[i]!
-                  const run = [first]
-                  let j = i + 1
-                  // Only batch adjacent files when their paths cannot affect each other.
-                  while (j < ops.length && run.length < 100) {
-                    const next = ops[j]!
-                    if (next.hash !== first.hash) break
-                    if (run.some((item) => clash(item.rel, next.rel))) break
-                    run.push(next)
-                    j += 1
-                  }
-
-                  if (run.length === 1) {
-                    yield* single(first)
-                    i = j
-                    continue
+                  for (const op of run) {
+                    yield* single(op)
                   }
+                  i = j
+                  continue
+                }
 
-                  const tree = yield* git(
-                    [...core, ...args(["ls-tree", "--name-only", first.hash, "--", ...run.map((item) => item.rel)])],
+                const have = new Set(
+                  tree.text
+                    .trim()
+                    .split("\n")
+                    .map((item) => item.trim())
+                    .filter(Boolean),
+                )
+                const list = run.filter((item) => have.has(item.rel))
+                if (list.length) {
+                  log.info("reverting", { hash: first.hash, files: list.length })
+                  const result = yield* git(
+                    [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
                     {
                       cwd: state.worktree,
                     },
                   )
-
-                  if (tree.code !== 0) {
-                    log.info("batched ls-tree failed, falling back to single-file revert", {
+                  if (result.code !== 0) {
+                    log.info("batched checkout failed, falling back to single-file revert", {
                       hash: first.hash,
-                      files: run.length,
+                      files: list.length,
                     })
                     for (const op of run) {
                       yield* single(op)
@@ -433,329 +460,299 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
                     i = j
                     continue
                   }
-
-                  const have = new Set(
-                    tree.text
-                      .trim()
-                      .split("\n")
-                      .map((item) => item.trim())
-                      .filter(Boolean),
-                  )
-                  const list = run.filter((item) => have.has(item.rel))
-                  if (list.length) {
-                    log.info("reverting", { hash: first.hash, files: list.length })
-                    const result = yield* git(
-                      [...core, ...args(["checkout", first.hash, "--", ...list.map((item) => item.file)])],
-                      {
-                        cwd: state.worktree,
-                      },
-                    )
-                    if (result.code !== 0) {
-                      log.info("batched checkout failed, falling back to single-file revert", {
-                        hash: first.hash,
-                        files: list.length,
-                      })
-                      for (const op of run) {
-                        yield* single(op)
-                      }
-                      i = j
-                      continue
-                    }
-                  }
-
-                  for (const op of run) {
-                    if (have.has(op.rel)) continue
-                    log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
-                    yield* remove(op.file)
-                  }
-
-                  i = j
                 }
-              }),
-            )
-          })
 
-          const diff = Effect.fnUntraced(function* (hash: string) {
-            return yield* locked(
-              Effect.gen(function* () {
-                yield* add()
-                const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
-                  cwd: state.worktree,
-                })
-                if (result.code !== 0) {
-                  log.warn("failed to get diff", {
-                    hash,
-                    exitCode: result.code,
-                    stderr: result.stderr,
-                  })
-                  return ""
+                for (const op of run) {
+                  if (have.has(op.rel)) continue
+                  log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash })
+                  yield* remove(op.file)
                 }
-                return result.text.trim()
-              }),
-            )
-          })
 
-          const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
-            return yield* locked(
-              Effect.gen(function* () {
-                type Row = {
-                  file: string
-                  status: "added" | "deleted" | "modified"
-                  binary: boolean
-                  additions: number
-                  deletions: number
+                i = j
+              }
+            }),
+          )
+        })
+
+        const diff = Effect.fnUntraced(function* (hash: string) {
+          return yield* locked(
+            Effect.gen(function* () {
+              yield* add()
+              const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
+                cwd: state.worktree,
+              })
+              if (result.code !== 0) {
+                log.warn("failed to get diff", {
+                  hash,
+                  exitCode: result.code,
+                  stderr: result.stderr,
+                })
+                return ""
+              }
+              return result.text.trim()
+            }),
+          )
+        })
+
+        const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
+          return yield* locked(
+            Effect.gen(function* () {
+              type Row = {
+                file: string
+                status: "added" | "deleted" | "modified"
+                binary: boolean
+                additions: number
+                deletions: number
+              }
+
+              type Ref = {
+                file: string
+                side: "before" | "after"
+                ref: string
+              }
+
+              const show = Effect.fnUntraced(function* (row: Row) {
+                if (row.binary) return ["", ""]
+                if (row.status === "added") {
+                  return [
+                    "",
+                    yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
+                  ]
                 }
-
-                type Ref = {
-                  file: string
-                  side: "before" | "after"
-                  ref: string
+                if (row.status === "deleted") {
+                  return [
+                    yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
+                      Effect.map((item) => item.text),
+                    ),
+                    "",
+                  ]
                 }
+                return yield* Effect.all(
+                  [
+                    git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
+                    git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
+                  ],
+                  { concurrency: 2 },
+                )
+              })
 
-                const show = Effect.fnUntraced(function* (row: Row) {
-                  if (row.binary) return ["", ""]
-                  if (row.status === "added") {
+              const load = Effect.fnUntraced(
+                function* (rows: Row[]) {
+                  const refs = rows.flatMap((row) => {
+                    if (row.binary) return []
+                    if (row.status === "added")
+                      return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
+                    if (row.status === "deleted") {
+                      return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
+                    }
                     return [
-                      "",
-                      yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(
-                        Effect.map((item) => item.text),
-                      ),
+                      { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
+                      { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
                     ]
+                  })
+                  if (!refs.length) return new Map<string, { before: string; after: string }>()
+
+                  const batch = yield* appProcess.run(
+                    ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
+                      cwd: state.directory,
+                      extendEnv: true,
+                    }),
+                    { stdin: refs.map((item) => item.ref).join("\n") + "\n" },
+                  )
+                  if (batch.exitCode !== 0) {
+                    log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
+                      stderr: batch.stderr.toString("utf8"),
+                      refs: refs.length,
+                    })
+                    return
                   }
-                  if (row.status === "deleted") {
-                    return [
-                      yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
-                        Effect.map((item) => item.text),
-                      ),
-                      "",
-                    ]
+                  const out = batch.stdout
+
+                  const fail = (msg: string, extra?: Record<string, string>) => {
+                    log.info(msg, { ...extra, refs: refs.length })
+                    return undefined
                   }
-                  return yield* Effect.all(
-                    [
-                      git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
-                      git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
-                    ],
-                    { concurrency: 2 },
-                  )
-                })
 
-                const load = Effect.fnUntraced(
-                  function* (rows: Row[]) {
-                    const refs = rows.flatMap((row) => {
-                      if (row.binary) return []
-                      if (row.status === "added")
-                        return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
-                      if (row.status === "deleted") {
-                        return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
-                      }
-                      return [
-                        { file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
-                        { file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
-                      ]
-                    })
-                    if (!refs.length) return new Map<string, { before: string; after: string }>()
-
-                    const batch = yield* appProcess.run(
-                      ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
-                        cwd: state.directory,
-                        extendEnv: true,
-                      }),
-                      { stdin: refs.map((item) => item.ref).join("\n") + "\n" },
-                    )
-                    if (batch.exitCode !== 0) {
-                      log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
-                        stderr: batch.stderr.toString("utf8"),
-                        refs: refs.length,
-                      })
-                      return
+                  const map = new Map<string, { before: string; after: string }>()
+                  const dec = new TextDecoder()
+                  let i = 0
+                  for (const ref of refs) {
+                    let end = i
+                    while (end < out.length && out[end] !== 10) end += 1
+                    if (end >= out.length) {
+                      return fail(
+                        "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
+                      )
                     }
-                    const out = batch.stdout
 
-                    const fail = (msg: string, extra?: Record<string, string>) => {
-                      log.info(msg, { ...extra, refs: refs.length })
-                      return undefined
+                    const head = dec.decode(out.slice(i, end))
+                    i = end + 1
+                    const hit = map.get(ref.file) ?? { before: "", after: "" }
+                    if (head.endsWith(" missing")) {
+                      map.set(ref.file, hit)
+                      continue
                     }
 
-                    const map = new Map<string, { before: string; after: string }>()
-                    const dec = new TextDecoder()
-                    let i = 0
-                    for (const ref of refs) {
-                      let end = i
-                      while (end < out.length && out[end] !== 10) end += 1
-                      if (end >= out.length) {
-                        return fail(
-                          "git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
-                        )
-                      }
-
-                      const head = dec.decode(out.slice(i, end))
-                      i = end + 1
-                      const hit = map.get(ref.file) ?? { before: "", after: "" }
-                      if (head.endsWith(" missing")) {
-                        map.set(ref.file, hit)
-                        continue
-                      }
-
-                      const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
-                      if (!match) {
-                        return fail(
-                          "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
-                          { head },
-                        )
-                      }
-
-                      const size = Number(match[1])
-                      if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
-                        return fail(
-                          "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
-                          { head },
-                        )
-                      }
-
-                      const text = dec.decode(out.slice(i, i + size))
-                      if (ref.side === "before") hit.before = text
-                      if (ref.side === "after") hit.after = text
-                      map.set(ref.file, hit)
-                      i += size + 1
+                    const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
+                    if (!match) {
+                      return fail(
+                        "git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
+                        { head },
+                      )
                     }
 
-                    if (i !== out.length) {
+                    const size = Number(match[1])
+                    if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
                       return fail(
-                        "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
+                        "git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
+                        { head },
                       )
                     }
 
-                    return map
-                  },
-                  Effect.scoped,
-                  Effect.catch(() =>
-                    Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
-                  ),
-                )
+                    const text = dec.decode(out.slice(i, i + size))
+                    if (ref.side === "before") hit.before = text
+                    if (ref.side === "after") hit.after = text
+                    map.set(ref.file, hit)
+                    i += size + 1
+                  }
 
-                const result: FileDiff[] = []
-                const status = new Map<string, "added" | "deleted" | "modified">()
+                  if (i !== out.length) {
+                    return fail(
+                      "git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
+                    )
+                  }
 
-                const statuses = yield* git(
-                  [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
-                  { cwd: state.directory },
-                )
+                  return map
+                },
+                Effect.scoped,
+                Effect.catch(() =>
+                  Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
+                ),
+              )
 
-                for (const line of statuses.text.trim().split("\n")) {
-                  if (!line) continue
-                  const [code, file] = line.split("\t")
-                  if (!code || !file) continue
-                  status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
-                }
+              const result: FileDiff[] = []
+              const status = new Map<string, "added" | "deleted" | "modified">()
 
-                const numstat = yield* git(
-                  [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
-                  {
-                    cwd: state.directory,
-                  },
-                )
+              const statuses = yield* git(
+                [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
+                { cwd: state.directory },
+              )
 
-                const rows = numstat.text
-                  .trim()
-                  .split("\n")
-                  .filter(Boolean)
-                  .flatMap((line) => {
-                    const [adds, dels, file] = line.split("\t")
-                    if (!file) return []
-                    const binary = adds === "-" && dels === "-"
-                    const additions = binary ? 0 : parseInt(adds)
-                    const deletions = binary ? 0 : parseInt(dels)
-                    return [
-                      {
-                        file,
-                        status: status.get(file) ?? "modified",
-                        binary,
-                        additions: Number.isFinite(additions) ? additions : 0,
-                        deletions: Number.isFinite(deletions) ? deletions : 0,
-                      } satisfies Row,
-                    ]
-                  })
+              for (const line of statuses.text.trim().split("\n")) {
+                if (!line) continue
+                const [code, file] = line.split("\t")
+                if (!code || !file) continue
+                status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
+              }
 
-                // Hide ignored-file removals from the user-facing diff output.
-                const ignored = yield* ignore(rows.map((r) => r.file))
-                if (ignored.size > 0) {
-                  const filtered = rows.filter((r) => !ignored.has(r.file))
-                  rows.length = 0
-                  rows.push(...filtered)
-                }
+              const numstat = yield* git(
+                [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
+                {
+                  cwd: state.directory,
+                },
+              )
 
-                const step = 100
-                const patch = (file: string, before: string, after: string) =>
-                  formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
-
-                for (let i = 0; i < rows.length; i += step) {
-                  const run = rows.slice(i, i + step)
-                  const text = yield* load(run)
-
-                  for (const row of run) {
-                    const hit = text?.get(row.file) ?? { before: "", after: "" }
-                    const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
-                    result.push({
-                      file: row.file,
-                      patch: row.binary ? "" : patch(row.file, before, after),
-                      additions: row.additions,
-                      deletions: row.deletions,
-                      status: row.status,
-                    })
-                  }
-                }
+              const rows = numstat.text
+                .trim()
+                .split("\n")
+                .filter(Boolean)
+                .flatMap((line) => {
+                  const [adds, dels, file] = line.split("\t")
+                  if (!file) return []
+                  const binary = adds === "-" && dels === "-"
+                  const additions = binary ? 0 : parseInt(adds)
+                  const deletions = binary ? 0 : parseInt(dels)
+                  return [
+                    {
+                      file,
+                      status: status.get(file) ?? "modified",
+                      binary,
+                      additions: Number.isFinite(additions) ? additions : 0,
+                      deletions: Number.isFinite(deletions) ? deletions : 0,
+                    } satisfies Row,
+                  ]
+                })
 
-                return result
-              }),
-            )
-          })
+              // Hide ignored-file removals from the user-facing diff output.
+              const ignored = yield* ignore(rows.map((r) => r.file))
+              if (ignored.size > 0) {
+                const filtered = rows.filter((r) => !ignored.has(r.file))
+                rows.length = 0
+                rows.push(...filtered)
+              }
+
+              const step = 100
+              const patch = (file: string, before: string, after: string) =>
+                formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
+
+              for (let i = 0; i < rows.length; i += step) {
+                const run = rows.slice(i, i + step)
+                const text = yield* load(run)
+
+                for (const row of run) {
+                  const hit = text?.get(row.file) ?? { before: "", after: "" }
+                  const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
+                  result.push({
+                    file: row.file,
+                    patch: row.binary ? "" : patch(row.file, before, after),
+                    additions: row.additions,
+                    deletions: row.deletions,
+                    status: row.status,
+                  })
+                }
+              }
 
-          yield* cleanup().pipe(
-            Effect.catchCause((cause) => {
-              log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
-              return Effect.void
+              return result
             }),
-            Effect.repeat(Schedule.spaced(Duration.hours(1))),
-            Effect.delay(Duration.minutes(1)),
-            Effect.forkScoped,
           )
-
-          return { cleanup, track, patch, restore, revert, diff, diffFull }
-        }),
-      )
-
-      return Service.of({
-        init: Effect.fn("Snapshot.init")(function* () {
-          yield* InstanceState.get(state)
-        }),
-        cleanup: Effect.fn("Snapshot.cleanup")(function* () {
-          return yield* InstanceState.useEffect(state, (s) => s.cleanup())
-        }),
-        track: Effect.fn("Snapshot.track")(function* () {
-          return yield* InstanceState.useEffect(state, (s) => s.track())
-        }),
-        patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
-          return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
-        }),
-        restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
-          return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
-        }),
-        revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) {
-          return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
-        }),
-        diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
-          return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
-        }),
-        diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
-          return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
-        }),
-      })
-    }),
-  )
+        })
+
+        yield* cleanup().pipe(
+          Effect.catchCause((cause) => {
+            log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
+            return Effect.void
+          }),
+          Effect.repeat(Schedule.spaced(Duration.hours(1))),
+          Effect.delay(Duration.minutes(1)),
+          Effect.forkScoped,
+        )
+
+        return { cleanup, track, patch, restore, revert, diff, diffFull }
+      }),
+    )
+
+    return Service.of({
+      init: Effect.fn("Snapshot.init")(function* () {
+        yield* InstanceState.get(state)
+      }),
+      cleanup: Effect.fn("Snapshot.cleanup")(function* () {
+        return yield* InstanceState.useEffect(state, (s) => s.cleanup())
+      }),
+      track: Effect.fn("Snapshot.track")(function* () {
+        return yield* InstanceState.useEffect(state, (s) => s.track())
+      }),
+      patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
+        return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
+      }),
+      restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+        return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
+      }),
+      revert: Effect.fn("Snapshot.revert")(function* (patches: Patch[]) {
+        return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
+      }),
+      diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
+        return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
+      }),
+      diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+        return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
+      }),
+    })
+  }),
+)
 
 export const defaultLayer = layer.pipe(
   Layer.provide(AppProcess.defaultLayer),
-  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Config.defaultLayer),
 )
 

+ 12 - 16
packages/opencode/src/storage/storage.ts

@@ -1,18 +1,14 @@
 import * as Log from "@opencode-ai/core/util/log"
 import path from "path"
 import { Global } from "@opencode-ai/core/global"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import { Git } from "@/git"
 
 const log = Log.create({ service: "storage" })
 
-type Migration = (
-  dir: string,
-  fs: AppFileSystem.Interface,
-  git: Git.Interface,
-) => Effect.Effect<void, AppFileSystem.Error>
+type Migration = (dir: string, fs: FSUtil.Interface, git: Git.Interface) => Effect.Effect<void, FSUtil.Error>
 
 export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("NotFoundError", {
   message: Schema.String,
@@ -22,7 +18,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Not
   }
 }
 
-export type Error = AppFileSystem.Error | NotFoundError
+export type Error = FSUtil.Error | NotFoundError
 
 const RootFile = Schema.Struct({
   path: Schema.optional(
@@ -57,11 +53,11 @@ const decodeMessage = Schema.decodeUnknownOption(MessageFile)
 const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
 
 export interface Interface {
-  readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
+  readonly remove: (key: string[]) => Effect.Effect<void, FSUtil.Error>
   readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
   readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
-  readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
-  readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
+  readonly write: <T>(key: string[], content: T) => Effect.Effect<void, FSUtil.Error>
+  readonly list: (prefix: string[]) => Effect.Effect<string[][], FSUtil.Error>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/Storage") {}
@@ -85,7 +81,7 @@ function parseMigration(text: string) {
 }
 
 const MIGRATIONS: Migration[] = [
-  Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) {
+  Effect.fn("Storage.migration.1")(function* (dir: string, fs: FSUtil.Interface, git: Git.Interface) {
     const project = path.resolve(dir, "../project")
     if (!(yield* fs.isDir(project))) return
     const projectDirs = yield* fs.glob("*", {
@@ -185,7 +181,7 @@ const MIGRATIONS: Migration[] = [
       }
     }
   }),
-  Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
+  Effect.fn("Storage.migration.2")(function* (dir: string, fs: FSUtil.Interface) {
     for (const item of yield* fs.glob("session/*/*.json", {
       cwd: dir,
       absolute: true,
@@ -219,7 +215,7 @@ const MIGRATIONS: Migration[] = [
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const git = yield* Git.Service
     const locks = yield* RcMap.make({
       lookup: () => TxReentrantLock.make(),
@@ -251,7 +247,7 @@ export const layer = Layer.effect(
     const fail = (target: string): Effect.Effect<never, NotFoundError> =>
       Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
 
-    const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
+    const wrap = <A>(target: string, body: Effect.Effect<A, FSUtil.Error>) =>
       body.pipe(Effect.catchIf(missing, () => fail(target)))
 
     const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
@@ -261,7 +257,7 @@ export const layer = Layer.effect(
     const withResolved = <A, E>(
       key: string[],
       fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
-    ): Effect.Effect<A, E | AppFileSystem.Error> =>
+    ): Effect.Effect<A, E | FSUtil.Error> =>
       Effect.scoped(
         Effect.gen(function* () {
           const target = file((yield* state).dir, key)
@@ -328,6 +324,6 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
+export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
 
 export * as Storage from "./storage"

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

@@ -2,16 +2,16 @@ import * as path from "path"
 import { Effect, Schema } from "effect"
 import * as Tool from "./tool"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { FileWatcher } from "../file/watcher"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
 import { InstanceState } from "@/effect/instance-state"
 import { Patch } from "../patch"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import { trimDiff } from "./edit"
 import { LSP } from "@/lsp/lsp"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import DESCRIPTION from "./apply_patch.txt"
-import { File } from "../file"
+import { FileSystem } from "@opencode-ai/core/filesystem"
 import { Format } from "../format"
 import * as Bom from "@/util/bom"
 
@@ -23,7 +23,7 @@ export const ApplyPatchTool = Tool.define(
   "apply_patch",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
-    const afs = yield* AppFileSystem.Service
+    const afs = yield* FSUtil.Service
     const format = yield* Format.Service
     const events = yield* EventV2Bridge.Service
 
@@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define(
           if (yield* format.file(edited)) {
             yield* Bom.syncFile(afs, edited, change.bom)
           }
-          yield* events.publish(File.Event.Edited, { file: edited })
+          yield* events.publish(FileSystem.Event.Edited, { file: edited })
         }
       }
 
       // Publish file change events
       for (const update of updates) {
-        yield* events.publish(FileWatcher.Event.Updated, update)
+        yield* events.publish(Watcher.Event.Updated, update)
       }
 
       // Notify LSP of file changes and collect diagnostics
@@ -286,7 +286,7 @@ export const ApplyPatchTool = Tool.define(
       for (const change of fileChanges) {
         if (change.type === "delete") continue
         const target = change.movePath ?? change.filePath
-        const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
+        const block = LSP.Diagnostic.report(target, diagnostics[FSUtil.normalizePath(target)] ?? [])
         if (!block) continue
         const rel = path.relative(instance.worktree, target).replaceAll("\\", "/")
         output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`

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

@@ -9,14 +9,14 @@ import * as Tool from "./tool"
 import { LSP } from "@/lsp/lsp"
 import { createTwoFilesPatch, diffLines } from "diff"
 import DESCRIPTION from "./edit.txt"
-import { File } from "../file"
-import { FileWatcher } from "../file/watcher"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { Format } from "../format"
 import { InstanceState } from "@/effect/instance-state"
 import { Snapshot } from "@/snapshot"
 import { assertExternalDirectoryEffect } from "./external-directory"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import * as Bom from "@/util/bom"
 
 function normalizeLineEndings(text: string): string {
@@ -35,7 +35,7 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
 const locks = new Map<string, Semaphore.Semaphore>()
 
 function lock(filePath: string) {
-  const resolvedFilePath = AppFileSystem.resolve(filePath)
+  const resolvedFilePath = FSUtil.resolve(filePath)
   const hit = locks.get(resolvedFilePath)
   if (hit) return hit
 
@@ -59,7 +59,7 @@ export const EditTool = Tool.define(
   "edit",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
-    const afs = yield* AppFileSystem.Service
+    const afs = yield* FSUtil.Service
     const format = yield* Format.Service
     const events = yield* EventV2Bridge.Service
 
@@ -108,8 +108,8 @@ export const EditTool = Tool.define(
                 if (yield* format.file(filePath)) {
                   contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
                 }
-                yield* events.publish(File.Event.Edited, { file: filePath })
-                yield* events.publish(FileWatcher.Event.Updated, {
+                yield* events.publish(FileSystem.Event.Edited, { file: filePath })
+                yield* events.publish(Watcher.Event.Updated, {
                   file: filePath,
                   event: existed ? "change" : "add",
                 })
@@ -152,8 +152,8 @@ export const EditTool = Tool.define(
               if (yield* format.file(filePath)) {
                 contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
               }
-              yield* events.publish(File.Event.Edited, { file: filePath })
-              yield* events.publish(FileWatcher.Event.Updated, {
+              yield* events.publish(FileSystem.Event.Edited, { file: filePath })
+              yield* events.publish(Watcher.Event.Updated, {
                 file: filePath,
                 event: "change",
               })
@@ -192,7 +192,7 @@ export const EditTool = Tool.define(
           let output = "Edit applied successfully."
           yield* lsp.touchFile(filePath, "document")
           const diagnostics = yield* lsp.diagnostics()
-          const normalizedFilePath = AppFileSystem.normalizePath(filePath)
+          const normalizedFilePath = FSUtil.normalizePath(filePath)
           const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
           if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
 

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

@@ -4,7 +4,7 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
 import { InstanceState } from "@/effect/instance-state"
 import type * as Tool from "./tool"
 import { containsPath } from "../project/instance-context"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 
 type Kind = "file" | "directory"
 
@@ -23,14 +23,14 @@ 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
+  const full = process.platform === "win32" ? FSUtil.normalizePath(target) : target
   if (containsPath(full, ins)) return
 
   const kind = options?.kind ?? "file"
   const dir = kind === "directory" ? full : path.dirname(full)
   const glob =
     process.platform === "win32"
-      ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
+      ? FSUtil.normalizePathPattern(path.join(dir, "*"))
       : path.join(dir, "*").replaceAll("\\", "/")
 
   yield* ctx.ask({

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

@@ -2,8 +2,8 @@ import path from "path"
 import { Effect, Option, Schema } from "effect"
 import * as Stream from "effect/Stream"
 import { InstanceState } from "@/effect/instance-state"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./glob.txt"
 import * as Tool from "./tool"
@@ -20,7 +20,7 @@ export const GlobTool = Tool.define(
   "glob",
   Effect.gen(function* () {
     const rg = yield* Ripgrep.Service
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const reference = yield* Reference.Service
 
     return {

+ 5 - 7
packages/opencode/src/tool/grep.ts

@@ -2,8 +2,8 @@ import path from "path"
 import { Schema } from "effect"
 import { Effect, Option } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Ripgrep } from "../file/ripgrep"
+import { FSUtil } from "@opencode-ai/core/fs-util"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import DESCRIPTION from "./grep.txt"
 import * as Tool from "./tool"
@@ -24,7 +24,7 @@ export const Parameters = Schema.Struct({
 export const GrepTool = Tool.define(
   "grep",
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const rg = yield* Ripgrep.Service
     const reference = yield* Reference.Service
 
@@ -64,7 +64,7 @@ export const GrepTool = Tool.define(
             kind: requestedInfo?.type === "Directory" ? "directory" : "file",
           })
 
-          const search = AppFileSystem.resolve(requested)
+          const search = FSUtil.resolve(requested)
           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)]
@@ -79,9 +79,7 @@ export const GrepTool = Tool.define(
           if (result.items.length === 0) return empty
 
           const rows = result.items.map((item) => ({
-            path: AppFileSystem.resolve(
-              path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
-            ),
+            path: FSUtil.resolve(path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text)),
             line: item.line_number,
             text: item.lines.text,
           }))

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

@@ -6,7 +6,7 @@ import DESCRIPTION from "./lsp.txt"
 import { InstanceState } from "@/effect/instance-state"
 import { pathToFileURL } from "url"
 import { assertExternalDirectoryEffect } from "./external-directory"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 
 const operations = [
   "goToDefinition",
@@ -38,7 +38,7 @@ export const LspTool = Tool.define(
   "lsp",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     return {
       description: DESCRIPTION,
       parameters: Parameters,

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

@@ -2,7 +2,7 @@ import { Effect, Option, Schema, Scope, Stream } from "effect"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
 import * as path from "path"
 import * as Tool from "./tool"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { LSP } from "@/lsp/lsp"
 import DESCRIPTION from "./read.txt"
 import { InstanceState } from "@/effect/instance-state"
@@ -39,7 +39,7 @@ export const Parameters = Schema.Struct({
 export const ReadTool = Tool.define(
   "read",
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const instruction = yield* Instruction.Service
     const lsp = yield* LSP.Service
     const reference = yield* Reference.Service
@@ -208,7 +208,7 @@ export const ReadTool = Tool.define(
         filepath = path.resolve(instance.directory, filepath)
       }
       if (process.platform === "win32") {
-        filepath = AppFileSystem.normalizePath(filepath)
+        filepath = FSUtil.normalizePath(filepath)
       }
       yield* reference.ensure(filepath)
       const title = path.relative(instance.worktree, filepath)
@@ -265,7 +265,7 @@ export const ReadTool = Tool.define(
       const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
       const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
 
-      const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
+      const mime = sniffAttachmentMime(sample, FSUtil.mimeType(filepath))
       const isImage = SUPPORTED_IMAGE_MIMES.has(mime)
 
       if (isImage || isPdfAttachment(mime)) {

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

@@ -34,7 +34,7 @@ import { Effect, Layer, Context } from "effect"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
 import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
-import { Ripgrep } from "../file/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { Format } from "../format"
 import { InstanceState } from "@/effect/instance-state"
 import { EffectBridge } from "@/effect/bridge"
@@ -42,7 +42,7 @@ import { Question } from "../question"
 import { Todo } from "../session/todo"
 import { LSP } from "@/lsp/lsp"
 import { Instruction } from "../session/instruction"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { Agent } from "../agent/agent"
 import { Skill } from "../skill"
@@ -96,7 +96,7 @@ export const layer: Layer.Layer<
   | Reference.Service
   | LSP.Service
   | Instruction.Service
-  | AppFileSystem.Service
+  | FSUtil.Service
   | EventV2Bridge.Service
   | HttpClient.HttpClient
   | ChildProcessSpawner
@@ -380,7 +380,7 @@ export const defaultLayer = Layer.suspend(() =>
       Layer.provide(Reference.defaultLayer),
       Layer.provide(LSP.defaultLayer),
       Layer.provide(Instruction.defaultLayer),
-      Layer.provide(AppFileSystem.defaultLayer),
+      Layer.provide(FSUtil.defaultLayer),
       Layer.provide(EventV2Bridge.defaultLayer),
       Layer.provide(FetchHttpClient.layer),
       Layer.provide(Format.defaultLayer),

+ 6 - 6
packages/opencode/src/tool/shell.ts

@@ -9,7 +9,7 @@ import { InstanceState } from "@/effect/instance-state"
 import { lazy } from "@/util/lazy"
 import { Language, type Node } from "web-tree-sitter"
 
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { fileURLToPath } from "url"
 import { Config } from "@/config/config"
 import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -266,7 +266,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole
 const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
   if (scan.dirs.size > 0) {
     const globs = Array.from(scan.dirs).map((dir) => {
-      if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
+      if (process.platform === "win32") return FSUtil.normalizePathPattern(path.join(dir, "*"))
       return path.join(dir, "*")
     })
     yield* ctx.ask({
@@ -336,7 +336,7 @@ export const ShellTool = Tool.define(
   Effect.gen(function* () {
     const config = yield* Config.Service
     const spawner = yield* ChildProcessSpawner
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const trunc = yield* Truncate.Service
     const plugin = yield* Plugin.Service
     const flags = yield* RuntimeFlags.Service
@@ -348,16 +348,16 @@ export const ShellTool = Tool.define(
         .pipe(Effect.catch(() => Effect.succeed([] as string[])))
       const file = lines[0]?.trim()
       if (!file) return
-      return AppFileSystem.normalizePath(file)
+      return FSUtil.normalizePath(file)
     })
 
     const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
       if (process.platform === "win32") {
-        if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
+        if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
           const file = yield* cygpath(shell, text)
           if (file) return file
         }
-        return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
+        return FSUtil.normalizePath(path.resolve(root, FSUtil.windowsPath(text)))
       }
       return path.resolve(root, text)
     })

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

@@ -2,7 +2,7 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { Effect, Schema } from "effect"
 import * as Stream from "effect/Stream"
-import { Ripgrep } from "../file/ripgrep"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { Skill } from "../skill"
 import * as Tool from "./tool"
 import DESCRIPTION from "./skill.txt"

+ 3 - 3
packages/opencode/src/tool/truncate.ts

@@ -2,7 +2,7 @@ import { NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
 import path from "path"
 import type { Agent } from "../agent/agent"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { evaluate } from "@/permission/evaluate"
 import { Config } from "@/config/config"
 import { Identifier } from "../id/id"
@@ -50,7 +50,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Tr
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
 
     const cleanup = Effect.fn("Truncate.cleanup")(function* () {
       const cutoff = Identifier.timestamp(
@@ -155,6 +155,6 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
+export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))
 
 export * as Truncate from "./truncate"

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

@@ -6,10 +6,10 @@ import { LSP } from "@/lsp/lsp"
 import { createTwoFilesPatch } from "diff"
 import DESCRIPTION from "./write.txt"
 import { EventV2Bridge } from "@/event-v2-bridge"
-import { File } from "../file"
-import { FileWatcher } from "../file/watcher"
+import { FileSystem } from "@opencode-ai/core/filesystem"
+import { Watcher } from "@opencode-ai/core/filesystem/watcher"
 import { Format } from "../format"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { InstanceState } from "@/effect/instance-state"
 import { trimDiff } from "./edit"
 import { assertExternalDirectoryEffect } from "./external-directory"
@@ -28,7 +28,7 @@ export const WriteTool = Tool.define(
   "write",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
-    const fs = yield* AppFileSystem.Service
+    const fs = yield* FSUtil.Service
     const events = yield* EventV2Bridge.Service
     const format = yield* Format.Service
 
@@ -65,8 +65,8 @@ export const WriteTool = Tool.define(
           if (yield* format.file(filepath)) {
             yield* Bom.syncFile(fs, filepath, desiredBom)
           }
-          yield* events.publish(File.Event.Edited, { file: filepath })
-          yield* events.publish(FileWatcher.Event.Updated, {
+          yield* events.publish(FileSystem.Event.Edited, { file: filepath })
+          yield* events.publish(Watcher.Event.Updated, {
             file: filepath,
             event: exists ? "change" : "add",
           })
@@ -74,7 +74,7 @@ export const WriteTool = Tool.define(
           let output = "Wrote file successfully."
           yield* lsp.touchFile(filepath, "document")
           const diagnostics = yield* lsp.diagnostics()
-          const normalizedFilepath = AppFileSystem.normalizePath(filepath)
+          const normalizedFilepath = FSUtil.normalizePath(filepath)
           let projectDiagnosticsCount = 0
           for (const [file, issues] of Object.entries(diagnostics)) {
             const current = file === normalizedFilepath

+ 3 - 7
packages/opencode/src/util/bom.ts

@@ -1,5 +1,5 @@
 import { Effect } from "effect"
-import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 
 const BOM_CODE = 0xfeff
 const BOM = String.fromCharCode(BOM_CODE)
@@ -15,15 +15,11 @@ export function join(text: string, bom: boolean) {
   return BOM + stripped
 }
 
-export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) {
+export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filePath: string) {
   return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)))
 })
 
-export const syncFile = Effect.fn("Bom.syncFile")(function* (
-  fs: AppFileSystem.Interface,
-  filePath: string,
-  bom: boolean,
-) {
+export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filePath: string, bom: boolean) {
   const current = yield* readFile(fs, filePath)
   if (current.bom === bom) return current.text
   yield* fs.writeWithDirs(filePath, join(current.text, bom))

Некоторые файлы не были показаны из-за большого количества измененных файлов