Pārlūkot izejas kodu

refactor(core): move mutation path onto environment (#41091)

Kit Langton 4 dienas atpakaļ
vecāks
revīzija
db31c42e39

+ 36 - 11
packages/core/src/file-mutation.ts

@@ -5,6 +5,8 @@ import { Context, Effect, Layer } from "effect"
 import { KeyedMutex } from "./effect/keyed-mutex"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { Bom } from "@opencode-ai/util/bom"
+import { Environment } from "./environment"
+import type { Files } from "./environment"
 
 export interface Target {
   readonly absolute: string
@@ -33,13 +35,29 @@ export interface Interface {
   readonly withLock: (
     targets: ReadonlyArray<string>,
   ) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
-  readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
+  readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
   /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
-  readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
+  readonly writeTextPreservingBom: (
+    input: TextWriteInput,
+  ) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
 
+export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
+  return Bom.decodeBytes((yield* files.read(target)).bytes)
+})
+
+export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
+  files: Files,
+  target: string,
+  bom: boolean,
+) {
+  const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
+  if (synced.bytes) yield* files.write(target, synced.bytes)
+  return synced.text
+})
+
 /** Share transaction locks across Location graphs that address the same file. */
 const transactionLocks = KeyedMutex.makeUnsafe<string>()
 
@@ -51,7 +69,7 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
 const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
-    const fs = yield* FSUtil.Service
+    const environment = yield* Environment.Service
     const locks = KeyedMutex.makeUnsafe<string>()
     const withLock: Interface["withLock"] = (targets) => (effect) =>
       [...new Set(targets.map(FSUtil.resolve))]
@@ -72,8 +90,14 @@ const layer = Layer.effect(
     const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
       withTargetLock(input.target)(
         Effect.gen(function* () {
-          const existed = yield* fs.exists(input.target.absolute)
-          yield* fs.writeWithDirs(input.target.absolute, input.content)
+          const existed = yield* environment.files.stat(input.target.absolute).pipe(
+            Effect.as(true),
+            Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
+          )
+          yield* environment.files.write(
+            input.target.absolute,
+            typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
+          )
           return writeResult(input.target, existed)
         }),
       ),
@@ -83,12 +107,13 @@ const layer = Layer.effect(
       withTargetLock(input.target)(
         Effect.gen(function* () {
           const next = Bom.split(input.content)
-          const current = yield* fs
-            .readFile(input.target.absolute)
-            .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
-          yield* fs.writeWithDirs(
+          const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
+            Effect.map((result) => result.bytes),
+            Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
+          )
+          yield* environment.files.write(
             input.target.absolute,
-            Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
+            new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
           )
           return writeResult(input.target, current !== undefined)
         }),
@@ -99,7 +124,7 @@ const layer = Layer.effect(
   }),
 )
 
-export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
+export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
 
 /**
  * Deferred until the corresponding integrations exist.

+ 3 - 0
packages/core/src/plugin/internal.ts

@@ -16,6 +16,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
 import { ConfigSkillPlugin } from "../config/plugin/skill"
 import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
 import { Bus } from "../bus"
+import { Environment } from "../environment"
 import { FileMutation } from "../file-mutation"
 import { Formatter } from "../formatter"
 import { Form } from "../form"
@@ -70,6 +71,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
   const config = yield* Config.Service
   const credential = yield* Credential.Service
   const bus = yield* Bus.Service
+  const environment = yield* Environment.Service
   const mutation = yield* FileMutation.Service
   const formatter = yield* Formatter.Service
   const filesystem = yield* FileSystem.Service
@@ -102,6 +104,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
     Context.make(Config.Service, config),
     Context.make(Credential.Service, credential),
     Context.make(Bus.Service, bus),
+    Context.make(Environment.Service, environment),
     Context.make(FileMutation.Service, mutation),
     Context.make(Formatter.Service, formatter),
     Context.make(FileSystem.Service, filesystem),

+ 5 - 1
packages/core/src/plugin/supervisor.ts

@@ -14,6 +14,7 @@ import { Credential } from "../credential"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
 import { Bus } from "../bus"
+import { Environment } from "../environment"
 import { FileMutation } from "../file-mutation"
 import { Formatter } from "../formatter"
 import { FileSystem } from "../filesystem"
@@ -282,7 +283,9 @@ const layer = Layer.effect(
     })
     const updates = Stream.merge(
       config.changes().pipe(
-        Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
+        Stream.filterEffect((update) =>
+          Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
+        ),
         Stream.merge(Stream.fromPubSub(configuredChanges)),
       ),
       bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -320,6 +323,7 @@ export const node = makeLocationNode({
     Config.node,
     Credential.node,
     Bus.node,
+    Environment.node,
     FileMutation.node,
     Formatter.node,
     FileSystem.node,

+ 17 - 18
packages/core/src/tool/plugin/edit.ts

@@ -12,9 +12,9 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { Bom } from "@opencode-ai/util/bom"
 import { Effect, Schema } from "effect"
 import path from "path"
+import { Environment } from "../../environment"
 import { FileMutation } from "../../file-mutation"
 import { Formatter } from "../../formatter"
-import { FSUtil } from "@opencode-ai/util/fs-util"
 import { Location } from "../../location"
 import { LocationMutation } from "../../location-mutation"
 import { Permission } from "../../permission"
@@ -111,9 +111,9 @@ export const Plugin = {
   id: "opencode.tool.edit",
   effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
     const mutation = yield* LocationMutation.Service
-    const files = yield* FileMutation.Service
+    const fileMutation = yield* FileMutation.Service
+    const environment = yield* Environment.Service
     const formatter = yield* Formatter.Service
-    const fs = yield* FSUtil.Service
     const location = yield* Location.Service
     const permission = yield* Permission.Service
 
@@ -155,17 +155,16 @@ export const Plugin = {
                 })
               }
 
-              const info = yield* fs
-                .stat(target.absolute)
-                .pipe(
-                  Effect.catchReason("PlatformError", "NotFound", () =>
-                    Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
-                  ),
-                )
-              if (info.type === "Directory") {
-                return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
-              }
-              const original = yield* Bom.readFile(fs, target.absolute)
+              const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
+                Effect.catchTag("Environment.NotFound", () =>
+                  Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
+                ),
+                Effect.catchTag("Environment.WrongKind", (error) =>
+                  error.actual === "directory"
+                    ? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
+                    : Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
+                ),
+              )
               const source = original.text
               const ending = source.includes(crlf) ? crlf : "\n"
               const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -207,20 +206,20 @@ export const Plugin = {
                 })
               }
               const replacementBom = replaced.startsWith("\uFEFF")
-              const result = yield* files.write({
+              const result = yield* fileMutation.write({
                 target,
                 content: Bom.join(replaced, original.bom || replacementBom),
               })
               const bom = original.bom || replacementBom
               const formatted = (yield* formatter.file(target.absolute))
-                ? yield* Bom.syncFile(fs, target.absolute, bom)
-                : (yield* Bom.readFile(fs, target.absolute)).text
+                ? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
+                : (yield* FileMutation.readText(environment.files, target.absolute)).text
               return {
                 files: [fileDiff(result.resource, source, formatted)],
                 replacements,
               } satisfies Output
             }).pipe(
-              files.withLock([path.resolve(location.directory, input.path)]),
+              fileMutation.withLock([path.resolve(location.directory, input.path)]),
               Effect.map((output) => ({
                 output,
                 content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,

+ 31 - 44
packages/core/src/tool/plugin/patch.ts

@@ -5,10 +5,10 @@ import { ToolFailure } from "@opencode-ai/ai"
 import { FileDiff } from "@opencode-ai/schema/file-diff"
 import { createTwoFilesPatch, diffLines } from "diff"
 import { Effect, Result, Schema } from "effect"
-import { PlatformError } from "effect/PlatformError"
 import path from "path"
 import { Bom } from "@opencode-ai/util/bom"
 import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Environment } from "../../environment"
 import { Formatter } from "../../formatter"
 import { FileMutation } from "../../file-mutation"
 import { Location } from "../../location"
@@ -45,7 +45,13 @@ export const toModelOutput = (output: Output) =>
   ].join("\n")
 
 type Prepared =
-  | (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
+  | (Extract<Patch.Hunk, { readonly type: "add" }> & {
+      readonly target: Target
+      readonly content: string
+      readonly before: string
+      readonly after: string
+    })
+  | (Extract<Patch.Hunk, { readonly type: "delete" }> & {
       readonly target: Target
       readonly before: string
       readonly after: string
@@ -70,7 +76,7 @@ interface Target {
 export const Plugin = {
   id: "opencode.tool.patch",
   effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
-    const fs = yield* FSUtil.Service
+    const environment = yield* Environment.Service
     const mutation = yield* FileMutation.Service
     const formatter = yield* Formatter.Service
     const location = yield* Location.Service
@@ -90,9 +96,7 @@ export const Plugin = {
             const lockTargets = Result.isSuccess(parsed)
               ? parsed.success.flatMap((hunk) => [
                   path.resolve(location.directory, hunk.path),
-                  ...(hunk.type === "update" && hunk.movePath
-                    ? [path.resolve(location.directory, hunk.movePath)]
-                    : []),
+                  ...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
                 ])
               : []
             const fail = (operation: string, error: unknown) => {
@@ -108,7 +112,7 @@ export const Plugin = {
                 id: context.id,
               }
               if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
-              const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
+              const hunks = yield* Effect.fromResult(parsed).pipe(
                 Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
               )
               if (hunks.length === 0) {
@@ -136,18 +140,19 @@ export const Plugin = {
                     })
                   }
                   if (hunk.type === "add") {
+                    const content =
+                      hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
                     prepared.push({
                       ...hunk,
                       target,
+                      content,
                       before: "",
-                      after: Bom.split(
-                        hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
-                      ).text,
+                      after: Bom.split(content).text,
                     })
                     return
                   }
                   if (hunk.type === "delete") {
-                    const content = yield* Bom.readFile(fs, target.absolute).pipe(
+                    const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
                       Effect.mapError(
                         (error) =>
                           new ToolFailure({
@@ -162,20 +167,7 @@ export const Plugin = {
                   const original =
                     previous ??
                     (yield* Effect.gen(function* () {
-                      const stats = yield* fs.stat(target.absolute).pipe(
-                        Effect.mapError(
-                          (error) =>
-                            new ToolFailure({
-                              message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
-                            }),
-                        ),
-                      )
-                      if (stats.type === "Directory") {
-                        return yield* new ToolFailure({
-                          message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
-                        })
-                      }
-                      const content = yield* Bom.readFile(fs, target.absolute).pipe(
+                      const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
                         Effect.mapError(
                           (error) =>
                             new ToolFailure({
@@ -244,13 +236,8 @@ export const Plugin = {
                 (change) =>
                   Effect.gen(function* () {
                     if (change.type === "add") {
-                      yield* fs
-                        .writeWithDirs(
-                          change.target.absolute,
-                          change.contents.endsWith("\n") || change.contents === ""
-                            ? change.contents
-                            : `${change.contents}\n`,
-                        )
+                      yield* environment.files
+                        .write(change.target.absolute, new TextEncoder().encode(change.content))
                         .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
                       applied.push({
                         type: change.type,
@@ -260,7 +247,7 @@ export const Plugin = {
                       return
                     }
                     if (change.type === "delete") {
-                      yield* fs
+                      yield* environment.files
                         .remove(change.target.absolute)
                         .pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
                       applied.push({
@@ -272,10 +259,10 @@ export const Plugin = {
                     }
                     if (change.moveTarget) {
                       const moveTarget = change.moveTarget
-                      yield* fs
-                        .writeWithDirs(moveTarget.absolute, change.content)
+                      yield* environment.files
+                        .write(moveTarget.absolute, new TextEncoder().encode(change.content))
                         .pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
-                      yield* fs
+                      yield* environment.files
                         .remove(change.target.absolute)
                         .pipe(
                           Effect.mapError((error) =>
@@ -289,8 +276,8 @@ export const Plugin = {
                       })
                       return
                     }
-                    yield* fs
-                      .writeWithDirs(change.target.absolute, change.content)
+                    yield* environment.files
+                      .write(change.target.absolute, new TextEncoder().encode(change.content))
                       .pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
                     applied.push({
                       type: change.type,
@@ -305,13 +292,13 @@ export const Plugin = {
                 [...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
                 (target) =>
                   Effect.gen(function* () {
-                    const current = yield* Bom.readFile(fs, target).pipe(
+                    const current = yield* FileMutation.readText(environment.files, target).pipe(
                       Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
                     )
                     formatted.set(
                       target,
                       (yield* formatter.file(target))
-                        ? yield* Bom.syncFile(fs, target, current.bom).pipe(
+                        ? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
                             Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
                           )
                         : current.text,
@@ -357,10 +344,10 @@ export const Plugin = {
 }
 
 function errorMessage(error: unknown) {
-  if (error instanceof PlatformError) {
-    if (error.reason._tag === "NotFound") return "file does not exist"
-    return error.reason.description ?? error.reason.message
-  }
+  if (error instanceof Environment.NotFound) return "file does not exist"
+  if (error instanceof Environment.WrongKind)
+    return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
+  if (error instanceof Environment.Failed) return errorMessage(error.cause)
   return error instanceof Error ? error.message : String(error)
 }
 

+ 10 - 8
packages/core/src/tool/plugin/write.ts

@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
 import { ToolFailure } from "@opencode-ai/ai"
 import { Effect, Schema } from "effect"
 import { Bom } from "@opencode-ai/util/bom"
-import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Environment } from "../../environment"
 import { FileMutation } from "../../file-mutation"
 import { Formatter } from "../../formatter"
 import { LocationMutation } from "../../location-mutation"
@@ -47,9 +47,9 @@ export const Plugin = {
   id: "opencode.tool.write",
   effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
     const mutation = yield* LocationMutation.Service
-    const files = yield* FileMutation.Service
+    const fileMutation = yield* FileMutation.Service
+    const environment = yield* Environment.Service
     const formatter = yield* Formatter.Service
-    const fs = yield* FSUtil.Service
     const permission = yield* Permission.Service
 
     yield* ctx.tool
@@ -77,8 +77,8 @@ export const Plugin = {
                   agent: context.agent,
                   source,
                 })
-              const current = yield* Bom.readFile(fs, target.absolute).pipe(
-                Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
+              const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
+                Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
               )
               const next = Bom.split(input.content)
               const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
@@ -91,9 +91,11 @@ export const Plugin = {
                 agent: context.agent,
                 source,
               })
-              const result = yield* files.writeTextPreservingBom({ target, content: input.content })
-              const bom = (yield* Bom.readFile(fs, target.absolute)).bom
-              if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
+              const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
+              const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
+              if (yield* formatter.file(target.absolute)) {
+                yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
+              }
               return result
             }).pipe(
               Effect.map((output) => ({ output, content: toModelOutput(output) })),

+ 12 - 12
packages/core/test/file-mutation.test.ts

@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
-import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Environment } from "@opencode-ai/core/environment"
 import { Location } from "@opencode-ai/core/location"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
 import { tmpdir } from "./fixture/tmpdir"
 import { it } from "./lib/effect"
 
-function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
+function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
   const activeLocation = Layer.succeed(
     Location.Service,
     Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
@@ -21,7 +21,7 @@ function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.n
   return Effect.provide(
     AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
       [Location.node, activeLocation],
-      [FSUtil.node, filesystemLayer],
+      [Environment.node, environmentLayer],
     ]),
   )
 }
@@ -242,16 +242,16 @@ describe("FileMutation", () => {
 
 function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
   return Layer.effect(
-    FSUtil.Service,
+    Environment.Service,
     Effect.gen(function* () {
-      const filesystem = yield* FSUtil.Service
-      return FSUtil.Service.of({
-        ...filesystem,
-        writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
-        writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
-        writeFileString: (target, content, options) =>
-          run(filesystem.writeFileString(target, content, options), target),
+      const environment = yield* Environment.Service
+      return Environment.Service.of({
+        ...environment,
+        files: {
+          ...environment.files,
+          write: (target, content) => run(environment.files.write(target, content), target),
+        },
       })
     }),
-  ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
+  ).pipe(Layer.provide(LayerNode.compile(Environment.node)))
 }

+ 31 - 33
packages/core/test/tool-edit.test.ts

@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+import { Environment } from "@opencode-ai/core/environment"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
 import { Formatter } from "@opencode-ai/core/formatter"
-import { FSUtil } from "@opencode-ai/util/fs-util"
 import { Location } from "@opencode-ai/core/location"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { Permission } from "@opencode-ai/core/permission"
@@ -23,7 +23,15 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
 const editToolNode = makeLocationNode({
   name: "test/edit-tool-plugin",
   layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
-  deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
+  deps: [
+    Tool.node,
+    LocationMutation.node,
+    FileMutation.node,
+    Environment.node,
+    Formatter.node,
+    Location.node,
+    Permission.node,
+  ],
 })
 
 const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -72,29 +80,28 @@ const reset = () => {
   formatFile = () => Effect.succeed(false)
 }
 
-const filesystem = Layer.effect(
-  FSUtil.Service,
+const environment = Layer.effect(
+  Environment.Service,
   Effect.gen(function* () {
-    const fs = yield* FSUtil.Service
-    return FSUtil.Service.of({
-      ...fs,
-      readFile: (target) =>
-        fs
-          .readFile(target)
-          .pipe(
-            Effect.tap((content) =>
-              Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
+    const current = yield* Environment.Service
+    return Environment.Service.of({
+      ...current,
+      files: {
+        ...current.files,
+        read: (target, range) =>
+          current.files
+            .read(target, range)
+            .pipe(
+              Effect.tap((result) =>
+                Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
+              ),
             ),
-          ),
-      writeWithDirs: (target, content, mode) =>
-        Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
-      writeFile: (target, content, options) =>
-        Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
-      writeFileString: (target, content, options) =>
-        Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
+        write: (target, content) =>
+          Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
+      },
     })
   }),
-).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
+).pipe(Layer.provide(LayerNode.compile(Environment.node)))
 
 const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
   const activeLocation = Layer.succeed(
@@ -106,15 +113,9 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
   }).pipe(
     Effect.provide(
       AppNodeBuilder.build(
-        LayerNode.group([
-          Tool.node,
-          Tool.node,
-          LocationMutation.node,
-          FileMutation.node,
-          editToolNode,
-        ]),
+        LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
         [
-          [FSUtil.node, filesystem],
+          [Environment.node, environment],
           [Location.node, activeLocation],
           [Formatter.node, formatter],
           [Permission.node, permission],
@@ -471,10 +472,7 @@ describe("EditTool", () => {
             withTool(tmp.path, (registry) =>
               Effect.gen(function* () {
                 expect(
-                  yield* executeTool(
-                    registry,
-                    call({ path: "missing.ts", oldString: "before", newString: "after" }),
-                  ),
+                  yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
                 ).toEqual({
                   status: "error",
                   error: { type: "tool.execution", message: "File not found: missing.ts" },

+ 27 - 44
packages/core/test/tool-patch.test.ts

@@ -2,9 +2,9 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Layer, Schema } from "effect"
-import { systemError } from "effect/PlatformError"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+import { Environment } from "@opencode-ai/core/environment"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { Formatter } from "@opencode-ai/core/formatter"
 import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
 const patchToolNode = makeLocationNode({
   name: "test/patch-tool-plugin",
   layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
-  deps: [Tool.node, FileMutation.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
+  deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
 })
 
 const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -82,48 +82,33 @@ const reset = () => {
   formatFile = () => Effect.succeed(false)
 }
 
-const filesystem = Layer.effect(
-  FSUtil.Service,
+const environment = Layer.effect(
+  Environment.Service,
   Effect.gen(function* () {
-    const fs = yield* FSUtil.Service
-    return FSUtil.Service.of({
-      ...fs,
-      readFile: (target) =>
-        Effect.sync(() => {
-          if (!editApproved) readsBeforeEditApproval++
-        }).pipe(Effect.andThen(fs.readFile(target))),
-      remove: (target, options) => {
-        if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
-        if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
-          return Effect.fail(
-            systemError({
-              _tag: "Unknown",
-              module: "FileSystem",
-              method: "remove",
-              description: "forced remove failure",
-              pathOrDescriptor: target,
-            }),
-          )
-        }
-        return fs.remove(target, options)
-      },
-      writeWithDirs: (target, content, mode) => {
-        if (failWriteTarget && path.basename(target) === failWriteTarget) {
-          return Effect.fail(
-            systemError({
-              _tag: "Unknown",
-              module: "FileSystem",
-              method: "writeWithDirs",
-              description: "forced write failure",
-              pathOrDescriptor: target,
-            }),
-          )
-        }
-        return fs.writeWithDirs(target, content, mode)
+    const current = yield* Environment.Service
+    return Environment.Service.of({
+      ...current,
+      files: {
+        ...current.files,
+        read: (target, range) =>
+          Effect.sync(() => {
+            if (!editApproved) readsBeforeEditApproval++
+          }).pipe(Effect.andThen(current.files.read(target, range))),
+        remove: (target) => {
+          if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
+          if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
+            return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
+          return current.files.remove(target)
+        },
+        write: (target, content) => {
+          if (failWriteTarget && path.basename(target) === failWriteTarget)
+            return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
+          return current.files.write(target, content)
+        },
       },
     })
   }),
-).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
+).pipe(Layer.provide(LayerNode.compile(Environment.node)))
 
 const withTool = <A, E, R>(
   directory: string,
@@ -141,7 +126,7 @@ const withTool = <A, E, R>(
   }).pipe(
     Effect.provide(
       AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
-        [FSUtil.node, filesystem],
+        [Environment.node, environment],
         [Location.node, activeLocation],
         [Formatter.node, formatter],
         [Permission.node, permission],
@@ -267,9 +252,7 @@ describe("PatchTool", () => {
     withTempTool((directory, registry) => {
       const target = path.join(directory, "concurrent.txt")
       afterEditApproval = () =>
-        assertions.filter((input) => input.action === "edit").length === 1
-          ? Effect.sleep("50 millis")
-          : Effect.void
+        assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
       return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
         Effect.andThen(
           Effect.all(

+ 14 - 11
packages/core/test/tool-write.test.ts

@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
 import { Formatter } from "@opencode-ai/core/formatter"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
-import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Environment } from "@opencode-ai/core/environment"
 import { Location } from "@opencode-ai/core/location"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
 import { Permission } from "@opencode-ai/core/permission"
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
 const writeToolNode = makeLocationNode({
   name: "test/write-tool-plugin",
   layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
-  deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
+  deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
 })
 
 const sessionID = Session.ID.make("ses_write_tool_test")
@@ -68,17 +68,20 @@ const reset = () => {
   denyAction = undefined
 }
 
-const filesystem = Layer.effect(
-  FSUtil.Service,
+const environment = Layer.effect(
+  Environment.Service,
   Effect.gen(function* () {
-    const fs = yield* FSUtil.Service
-    return FSUtil.Service.of({
-      ...fs,
-      writeWithDirs: (target, content, mode) =>
-        Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
+    const current = yield* Environment.Service
+    return Environment.Service.of({
+      ...current,
+      files: {
+        ...current.files,
+        write: (target, content) =>
+          Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
+      },
     })
   }),
-).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
+).pipe(Layer.provide(LayerNode.compile(Environment.node)))
 
 const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
   const activeLocation = Layer.succeed(
@@ -92,7 +95,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
       AppNodeBuilder.build(
         LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
         [
-          [FSUtil.node, filesystem],
+          [Environment.node, environment],
           [Location.node, activeLocation],
           [Formatter.node, formatter],
           [Permission.node, permission],

+ 15 - 7
packages/util/src/bom.ts

@@ -20,17 +20,25 @@ export function has(content: Uint8Array) {
   return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
 }
 
+export function decodeBytes(content: Uint8Array) {
+  return split(decode(content))
+}
+
+export function syncBytes(content: Uint8Array, bom: boolean) {
+  const decoded = decode(content)
+  const current = split(decoded)
+  const canonical = join(current.text, bom)
+  return { text: current.text, bytes: decoded === canonical ? undefined : new TextEncoder().encode(canonical) }
+}
+
 export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
-  return split(decode(yield* fs.readFile(filepath)))
+  return decodeBytes(yield* fs.readFile(filepath))
 })
 
 export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
-  const decoded = decode(yield* fs.readFile(filepath))
-  const current = split(decoded)
-  const canonical = join(current.text, bom)
-  if (decoded === canonical) return current.text
-  yield* fs.writeWithDirs(filepath, canonical)
-  return current.text
+  const synced = syncBytes(yield* fs.readFile(filepath), bom)
+  if (synced.bytes) yield* fs.writeWithDirs(filepath, synced.bytes)
+  return synced.text
 })
 
 function decode(content: Uint8Array) {