فهرست منبع

refactor(tool): Tool.Def.execute returns Effect, rename defineEffect → define (#21961)

Kit Langton 6 روز پیش
والد
کامیت
c5fb6281f0
39فایلهای تغییر یافته به همراه674 افزوده شده و 721 حذف شده
  1. 9 6
      packages/opencode/src/cli/cmd/debug/agent.ts
  2. 4 4
      packages/opencode/src/file/time.ts
  3. 70 61
      packages/opencode/src/session/prompt.ts
  4. 15 18
      packages/opencode/src/tool/apply_patch.ts
  5. 14 18
      packages/opencode/src/tool/bash.ts
  6. 11 13
      packages/opencode/src/tool/codesearch.ts
  7. 71 65
      packages/opencode/src/tool/edit.ts
  8. 10 10
      packages/opencode/src/tool/external-directory.ts
  9. 11 13
      packages/opencode/src/tool/glob.ts
  10. 12 14
      packages/opencode/src/tool/grep.ts
  11. 16 13
      packages/opencode/src/tool/invalid.ts
  12. 10 12
      packages/opencode/src/tool/ls.ts
  13. 3 3
      packages/opencode/src/tool/lsp.ts
  14. 10 12
      packages/opencode/src/tool/multiedit.ts
  15. 2 2
      packages/opencode/src/tool/plan.ts
  16. 2 2
      packages/opencode/src/tool/question.ts
  17. 8 12
      packages/opencode/src/tool/read.ts
  18. 28 18
      packages/opencode/src/tool/registry.ts
  19. 8 10
      packages/opencode/src/tool/skill.ts
  20. 11 15
      packages/opencode/src/tool/task.ts
  21. 18 19
      packages/opencode/src/tool/todo.ts
  22. 39 47
      packages/opencode/src/tool/tool.ts
  23. 12 14
      packages/opencode/src/tool/webfetch.ts
  24. 14 16
      packages/opencode/src/tool/websearch.ts
  25. 19 21
      packages/opencode/src/tool/write.ts
  26. 9 17
      packages/opencode/test/session/prompt-effect.test.ts
  27. 1 1
      packages/opencode/test/session/snapshot-tool-race.test.ts
  28. 6 6
      packages/opencode/test/tool/apply_patch.test.ts
  29. 81 81
      packages/opencode/test/tool/bash.test.ts
  30. 67 58
      packages/opencode/test/tool/edit.test.ts
  31. 17 49
      packages/opencode/test/tool/external-directory.test.ts
  32. 7 7
      packages/opencode/test/tool/grep.test.ts
  33. 3 3
      packages/opencode/test/tool/question.test.ts
  34. 15 15
      packages/opencode/test/tool/read.test.ts
  35. 3 4
      packages/opencode/test/tool/skill.test.ts
  36. 13 22
      packages/opencode/test/tool/task.test.ts
  37. 15 12
      packages/opencode/test/tool/tool-define.test.ts
  38. 5 5
      packages/opencode/test/tool/webfetch.test.ts
  39. 5 3
      packages/opencode/test/tool/write.test.ts

+ 9 - 6
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -1,5 +1,6 @@
 import { EOL } from "os"
 import { basename } from "path"
+import { Effect } from "effect"
 import { Agent } from "../../../agent/agent"
 import { Provider } from "../../../provider/provider"
 import { Session } from "../../../session"
@@ -158,13 +159,15 @@ async function createToolContext(agent: Agent.Info) {
     abort: new AbortController().signal,
     messages: [],
     metadata: () => {},
-    async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
-      for (const pattern of req.patterns) {
-        const rule = Permission.evaluate(req.permission, pattern, ruleset)
-        if (rule.action === "deny") {
-          throw new Permission.DeniedError({ ruleset })
+    ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
+      return Effect.sync(() => {
+        for (const pattern of req.patterns) {
+          const rule = Permission.evaluate(req.permission, pattern, ruleset)
+          if (rule.action === "deny") {
+            throw new Permission.DeniedError({ ruleset })
+          }
         }
-      }
+      })
     },
   }
 }

+ 4 - 4
packages/opencode/src/file/time.ts

@@ -34,7 +34,7 @@ export namespace FileTime {
     readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
     readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
     readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
-    readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
+    readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
   }
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
@@ -103,8 +103,8 @@ export namespace FileTime {
         )
       })
 
-      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
-        return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
+      const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
+        return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
       })
 
       return Service.of({ read, get, assert, withLock })
@@ -128,6 +128,6 @@ export namespace FileTime {
   }
 
   export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
-    return runPromise((s) => s.withLock(filepath, fn))
+    return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
   }
 }

+ 70 - 61
packages/opencode/src/session/prompt.ts

@@ -103,6 +103,13 @@ export namespace SessionPrompt {
       const state = yield* SessionRunState.Service
       const revert = yield* SessionRevert.Service
 
+      const run = {
+        promise: <A, E>(effect: Effect.Effect<A, E>) =>
+          Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
+        fork: <A, E>(effect: Effect.Effect<A, E>) =>
+          Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
+      }
+
       const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
         yield* elog.info("cancel", { sessionID })
         yield* state.cancel(sessionID)
@@ -358,7 +365,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           agent: input.agent.name,
           messages: input.messages,
           metadata: (val) =>
-            Effect.runPromise(
+            run.promise(
               input.processor.updateToolCall(options.toolCallId, (match) => {
                 if (!["running", "pending"].includes(match.state.status)) return match
                 return {
@@ -374,14 +381,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
               }),
             ),
           ask: (req) =>
-            Effect.runPromise(
-              permission.ask({
+            permission
+              .ask({
                 ...req,
                 sessionID: input.session.id,
                 tool: { messageID: input.processor.message.id, callID: options.toolCallId },
                 ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
-              }),
-            ),
+              })
+              .pipe(Effect.orDie),
         })
 
         for (const item of yield* registry.tools({
@@ -395,7 +402,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
             description: item.description,
             inputSchema: jsonSchema(schema as any),
             execute(args, options) {
-              return Effect.runPromise(
+              return run.promise(
                 Effect.gen(function* () {
                   const ctx = context(args, options)
                   yield* plugin.trigger(
@@ -403,7 +410,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
                     { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
                     { args },
                   )
-                  const result = yield* Effect.promise(() => item.execute(args, ctx))
+                  const result = yield* item.execute(args, ctx)
                   const output = {
                     ...result,
                     attachments: result.attachments?.map((attachment) => ({
@@ -436,7 +443,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
           const transformed = ProviderTransform.schema(input.model, schema)
           item.inputSchema = jsonSchema(transformed)
           item.execute = (args, opts) =>
-            Effect.runPromise(
+            run.promise(
               Effect.gen(function* () {
                 const ctx = context(args, opts)
                 yield* plugin.trigger(
@@ -444,7 +451,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
                   { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
                   { args },
                 )
-                yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }))
+                yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
                 const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
                   execute(args, opts),
                 )
@@ -576,45 +583,46 @@ NOTE: At any point in time through this workflow you should feel free to ask the
         }
 
         let error: Error | undefined
-        const result = yield* Effect.promise((signal) =>
-          taskTool
-            .execute(taskArgs, {
-              agent: task.agent,
-              messageID: assistantMessage.id,
-              sessionID,
-              abort: signal,
-              callID: part.callID,
-              extra: { bypassAgentCheck: true, promptOps },
-              messages: msgs,
-              metadata(val: { title?: string; metadata?: Record<string, any> }) {
-                return Effect.runPromise(
-                  Effect.gen(function* () {
-                    part = yield* sessions.updatePart({
-                      ...part,
-                      type: "tool",
-                      state: { ...part.state, ...val },
-                    } satisfies MessageV2.ToolPart)
-                  }),
-                )
-              },
-              ask(req: any) {
-                return Effect.runPromise(
-                  permission.ask({
-                    ...req,
-                    sessionID,
-                    ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
-                  }),
-                )
-              },
-            })
-            .catch((e) => {
-              error = e instanceof Error ? e : new Error(String(e))
+        const taskAbort = new AbortController()
+        const result = yield* taskTool
+          .execute(taskArgs, {
+            agent: task.agent,
+            messageID: assistantMessage.id,
+            sessionID,
+            abort: taskAbort.signal,
+            callID: part.callID,
+            extra: { bypassAgentCheck: true, promptOps },
+            messages: msgs,
+            metadata(val: { title?: string; metadata?: Record<string, any> }) {
+              return run.promise(
+                Effect.gen(function* () {
+                  part = yield* sessions.updatePart({
+                    ...part,
+                    type: "tool",
+                    state: { ...part.state, ...val },
+                  } satisfies MessageV2.ToolPart)
+                }),
+              )
+            },
+            ask: (req: any) =>
+              permission
+                .ask({
+                  ...req,
+                  sessionID,
+                  ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
+                })
+                .pipe(Effect.orDie),
+          })
+          .pipe(
+            Effect.catchCause((cause) => {
+              const defect = Cause.squash(cause)
+              error = defect instanceof Error ? defect : new Error(String(defect))
               log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
-              return undefined
+              return Effect.void
             }),
-        ).pipe(
-          Effect.onInterrupt(() =>
-            Effect.gen(function* () {
+            Effect.onInterrupt(() =>
+              Effect.gen(function* () {
+              taskAbort.abort()
               assistantMessage.finish = "tool-calls"
               assistantMessage.time.completed = Date.now()
               yield* sessions.updateMessage(assistantMessage)
@@ -630,9 +638,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
                   },
                 } satisfies MessageV2.ToolPart)
               }
-            }),
-          ),
-        )
+            })),
+          )
 
         const attachments = result?.attachments?.map((attachment) => ({
           ...attachment,
@@ -855,7 +862,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
               output += chunk
               if (part.state.status === "running") {
                 part.state.metadata = { output, description: "" }
-                void Effect.runFork(sessions.updatePart(part))
+                void run.fork(sessions.updatePart(part))
               }
             }),
           )
@@ -1037,19 +1044,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
                 if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
 
                 const { read } = yield* registry.named()
-                const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
-                  Effect.promise((signal: AbortSignal) =>
-                    read.execute(args, {
+                const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
+                  const controller = new AbortController()
+                  return read
+                    .execute(args, {
                       sessionID: input.sessionID,
-                      abort: signal,
+                      abort: controller.signal,
                       agent: input.agent!,
                       messageID: info.id,
                       extra: { bypassCwdCheck: true, ...extra },
                       messages: [],
-                      metadata: async () => {},
-                      ask: async () => {},
-                    }),
-                  )
+                      metadata: () => {},
+                      ask: () => Effect.void,
+                    })
+                    .pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
+                }
 
                 if (part.mime === "text/plain") {
                   let offset: number | undefined
@@ -1655,9 +1664,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       })
 
       const promptOps: TaskPromptOps = {
-        cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
-        resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
-        prompt: (input) => Effect.runPromise(prompt(input)),
+        cancel: (sessionID) => run.fork(cancel(sessionID)),
+        resolvePromptParts: (template) => run.promise(resolvePromptParts(template)),
+        prompt: (input) => run.promise(prompt(input)),
       }
 
       return Service.of({

+ 15 - 18
packages/opencode/src/tool/apply_patch.ts

@@ -19,12 +19,13 @@ const PatchParams = z.object({
   patchText: z.string().describe("The full patch text that describes all changes to be made"),
 })
 
-export const ApplyPatchTool = Tool.defineEffect(
+export const ApplyPatchTool = Tool.define(
   "apply_patch",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
     const afs = yield* AppFileSystem.Service
     const format = yield* Format.Service
+    const bus = yield* Bus.Service
 
     const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
       if (!params.patchText) {
@@ -178,18 +179,16 @@ export const ApplyPatchTool = Tool.defineEffect(
 
       // Check permissions if needed
       const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
-      yield* Effect.promise(() =>
-        ctx.ask({
-          permission: "edit",
-          patterns: relativePaths,
-          always: ["*"],
-          metadata: {
-            filepath: relativePaths.join(", "),
-            diff: totalDiff,
-            files,
-          },
-        }),
-      )
+      yield* ctx.ask({
+        permission: "edit",
+        patterns: relativePaths,
+        always: ["*"],
+        metadata: {
+          filepath: relativePaths.join(", "),
+          diff: totalDiff,
+          files,
+        },
+      })
 
       // Apply the changes
       const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
@@ -228,13 +227,13 @@ export const ApplyPatchTool = Tool.defineEffect(
 
         if (edited) {
           yield* format.file(edited)
-          Bus.publish(File.Event.Edited, { file: edited })
+          yield* bus.publish(File.Event.Edited, { file: edited })
         }
       }
 
       // Publish file change events
       for (const update of updates) {
-        Bus.publish(FileWatcher.Event.Updated, update)
+        yield* bus.publish(FileWatcher.Event.Updated, update)
       }
 
       // Notify LSP of file changes and collect diagnostics
@@ -281,9 +280,7 @@ export const ApplyPatchTool = Tool.defineEffect(
     return {
       description: DESCRIPTION,
       parameters: PatchParams,
-      async execute(params: z.infer<typeof PatchParams>, ctx) {
-        return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
-      },
+      execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
   }),
 )

+ 14 - 18
packages/opencode/src/tool/bash.ts

@@ -226,25 +226,21 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
       if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
       return path.join(dir, "*")
     })
-    yield* Effect.promise(() =>
-      ctx.ask({
-        permission: "external_directory",
-        patterns: globs,
-        always: globs,
-        metadata: {},
-      }),
-    )
+    yield* ctx.ask({
+      permission: "external_directory",
+      patterns: globs,
+      always: globs,
+      metadata: {},
+    })
   }
 
   if (scan.patterns.size === 0) return
-  yield* Effect.promise(() =>
-    ctx.ask({
-      permission: "bash",
-      patterns: Array.from(scan.patterns),
-      always: Array.from(scan.always),
-      metadata: {},
-    }),
-  )
+  yield* ctx.ask({
+    permission: "bash",
+    patterns: Array.from(scan.patterns),
+    always: Array.from(scan.always),
+    metadata: {},
+  })
 })
 
 function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
@@ -294,7 +290,7 @@ const parser = lazy(async () => {
 })
 
 // TODO: we may wanna rename this tool so it works better on other shells
-export const BashTool = Tool.defineEffect(
+export const BashTool = Tool.define(
   "bash",
   Effect.gen(function* () {
     const spawner = yield* ChildProcessSpawner
@@ -504,7 +500,7 @@ export const BashTool = Tool.defineEffect(
               },
               ctx,
             )
-          }).pipe(Effect.orDie, Effect.runPromise),
+          }),
       }
     }
   }),

+ 11 - 13
packages/opencode/src/tool/codesearch.ts

@@ -5,7 +5,7 @@ import { Tool } from "./tool"
 import * as McpExa from "./mcp-exa"
 import DESCRIPTION from "./codesearch.txt"
 
-export const CodeSearchTool = Tool.defineEffect(
+export const CodeSearchTool = Tool.define(
   "codesearch",
   Effect.gen(function* () {
     const http = yield* HttpClient.HttpClient
@@ -29,17 +29,15 @@ export const CodeSearchTool = Tool.defineEffect(
       }),
       execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
         Effect.gen(function* () {
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "codesearch",
-              patterns: [params.query],
-              always: ["*"],
-              metadata: {
-                query: params.query,
-                tokensNum: params.tokensNum,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "codesearch",
+            patterns: [params.query],
+            always: ["*"],
+            metadata: {
+              query: params.query,
+              tokensNum: params.tokensNum,
+            },
+          })
 
           const result = yield* McpExa.call(
             http,
@@ -59,7 +57,7 @@ export const CodeSearchTool = Tool.defineEffect(
             title: `Code search: ${params.query}`,
             metadata: {},
           }
-        }).pipe(Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 71 - 65
packages/opencode/src/tool/edit.ts

@@ -19,6 +19,7 @@ import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
 import { Snapshot } from "@/snapshot"
 import { assertExternalDirectoryEffect } from "./external-directory"
+import { AppFileSystem } from "../filesystem"
 
 function normalizeLineEndings(text: string): string {
   return text.replaceAll("\r\n", "\n")
@@ -40,11 +41,14 @@ const Parameters = z.object({
   replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
 })
 
-export const EditTool = Tool.defineEffect(
+export const EditTool = Tool.define(
   "edit",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
     const filetime = yield* FileTime.Service
+    const afs = yield* AppFileSystem.Service
+    const format = yield* Format.Service
+    const bus = yield* Bus.Service
 
     return {
       description: DESCRIPTION,
@@ -67,12 +71,53 @@ export const EditTool = Tool.defineEffect(
           let diff = ""
           let contentOld = ""
           let contentNew = ""
-          yield* filetime.withLock(filePath, async () => {
-            if (params.oldString === "") {
-              const existed = await Filesystem.exists(filePath)
-              contentNew = params.newString
-              diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
-              await ctx.ask({
+          yield* filetime.withLock(filePath, () =>
+            Effect.gen(function* () {
+              if (params.oldString === "") {
+                const existed = yield* afs.existsSafe(filePath)
+                contentNew = params.newString
+                diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
+                yield* ctx.ask({
+                  permission: "edit",
+                  patterns: [path.relative(Instance.worktree, filePath)],
+                  always: ["*"],
+                  metadata: {
+                    filepath: filePath,
+                    diff,
+                  },
+                })
+                yield* afs.writeWithDirs(filePath, params.newString)
+                yield* format.file(filePath)
+                yield* bus.publish(File.Event.Edited, { file: filePath })
+                yield* bus.publish(FileWatcher.Event.Updated, {
+                  file: filePath,
+                  event: existed ? "change" : "add",
+                })
+                yield* filetime.read(ctx.sessionID, filePath)
+                return
+              }
+
+              const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+              if (!info) throw new Error(`File ${filePath} not found`)
+              if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
+              yield* filetime.assert(ctx.sessionID, filePath)
+              contentOld = yield* afs.readFileString(filePath)
+
+              const ending = detectLineEnding(contentOld)
+              const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
+              const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
+
+              contentNew = replace(contentOld, old, next, params.replaceAll)
+
+              diff = trimDiff(
+                createTwoFilesPatch(
+                  filePath,
+                  filePath,
+                  normalizeLineEndings(contentOld),
+                  normalizeLineEndings(contentNew),
+                ),
+              )
+              yield* ctx.ask({
                 permission: "edit",
                 patterns: [path.relative(Instance.worktree, filePath)],
                 always: ["*"],
@@ -81,65 +126,26 @@ export const EditTool = Tool.defineEffect(
                   diff,
                 },
               })
-              await Filesystem.write(filePath, params.newString)
-              await Format.file(filePath)
-              Bus.publish(File.Event.Edited, { file: filePath })
-              await Bus.publish(FileWatcher.Event.Updated, {
+
+              yield* afs.writeWithDirs(filePath, contentNew)
+              yield* format.file(filePath)
+              yield* bus.publish(File.Event.Edited, { file: filePath })
+              yield* bus.publish(FileWatcher.Event.Updated, {
                 file: filePath,
-                event: existed ? "change" : "add",
+                event: "change",
               })
-              await FileTime.read(ctx.sessionID, filePath)
-              return
-            }
-
-            const stats = Filesystem.stat(filePath)
-            if (!stats) throw new Error(`File ${filePath} not found`)
-            if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
-            await FileTime.assert(ctx.sessionID, filePath)
-            contentOld = await Filesystem.readText(filePath)
-
-            const ending = detectLineEnding(contentOld)
-            const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
-            const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
-
-            contentNew = replace(contentOld, old, next, params.replaceAll)
-
-            diff = trimDiff(
-              createTwoFilesPatch(
-                filePath,
-                filePath,
-                normalizeLineEndings(contentOld),
-                normalizeLineEndings(contentNew),
-              ),
-            )
-            await ctx.ask({
-              permission: "edit",
-              patterns: [path.relative(Instance.worktree, filePath)],
-              always: ["*"],
-              metadata: {
-                filepath: filePath,
-                diff,
-              },
-            })
-
-            await Filesystem.write(filePath, contentNew)
-            await Format.file(filePath)
-            Bus.publish(File.Event.Edited, { file: filePath })
-            await Bus.publish(FileWatcher.Event.Updated, {
-              file: filePath,
-              event: "change",
-            })
-            contentNew = await Filesystem.readText(filePath)
-            diff = trimDiff(
-              createTwoFilesPatch(
-                filePath,
-                filePath,
-                normalizeLineEndings(contentOld),
-                normalizeLineEndings(contentNew),
-              ),
-            )
-            await FileTime.read(ctx.sessionID, filePath)
-          })
+              contentNew = yield* afs.readFileString(filePath)
+              diff = trimDiff(
+                createTwoFilesPatch(
+                  filePath,
+                  filePath,
+                  normalizeLineEndings(contentOld),
+                  normalizeLineEndings(contentNew),
+                ),
+              )
+              yield* filetime.read(ctx.sessionID, filePath)
+            }).pipe(Effect.orDie),
+          )
 
           const filediff: Snapshot.FileDiff = {
             file: filePath,
@@ -176,7 +182,7 @@ export const EditTool = Tool.defineEffect(
             title: `${path.relative(Instance.worktree, filePath)}`,
             output,
           }
-        }).pipe(Effect.orDie, Effect.runPromise),
+        }),
     }
   }),
 )

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

@@ -11,7 +11,11 @@ type Options = {
   kind?: Kind
 }
 
-export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
+export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
+  ctx: Tool.Context,
+  target?: string,
+  options?: Options,
+) {
   if (!target) return
 
   if (options?.bypass) return
@@ -26,7 +30,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
       ? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
       : path.join(dir, "*").replaceAll("\\", "/")
 
-  await ctx.ask({
+  yield* ctx.ask({
     permission: "external_directory",
     patterns: [glob],
     always: [glob],
@@ -35,12 +39,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
       parentDir: dir,
     },
   })
-}
-
-export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
-  ctx: Tool.Context,
-  target?: string,
-  options?: Options,
-) {
-  yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
 })
+
+export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
+  return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options))
+}

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

@@ -9,7 +9,7 @@ import { Instance } from "../project/instance"
 import { assertExternalDirectoryEffect } from "./external-directory"
 import { AppFileSystem } from "../filesystem"
 
-export const GlobTool = Tool.defineEffect(
+export const GlobTool = Tool.define(
   "glob",
   Effect.gen(function* () {
     const rg = yield* Ripgrep.Service
@@ -28,17 +28,15 @@ export const GlobTool = Tool.defineEffect(
       }),
       execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
         Effect.gen(function* () {
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "glob",
-              patterns: [params.pattern],
-              always: ["*"],
-              metadata: {
-                pattern: params.pattern,
-                path: params.path,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "glob",
+            patterns: [params.pattern],
+            always: ["*"],
+            metadata: {
+              pattern: params.pattern,
+              path: params.path,
+            },
+          })
 
           let search = params.path ?? Instance.directory
           search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
@@ -90,7 +88,7 @@ export const GlobTool = Tool.defineEffect(
             },
             output: output.join("\n"),
           }
-        }).pipe(Effect.orDie, Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 12 - 14
packages/opencode/src/tool/grep.ts

@@ -14,7 +14,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
 
 const MAX_LINE_LENGTH = 2000
 
-export const GrepTool = Tool.defineEffect(
+export const GrepTool = Tool.define(
   "grep",
   Effect.gen(function* () {
     const spawner = yield* ChildProcessSpawner
@@ -32,18 +32,16 @@ export const GrepTool = Tool.defineEffect(
             throw new Error("pattern is required")
           }
 
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "grep",
-              patterns: [params.pattern],
-              always: ["*"],
-              metadata: {
-                pattern: params.pattern,
-                path: params.path,
-                include: params.include,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "grep",
+            patterns: [params.pattern],
+            always: ["*"],
+            metadata: {
+              pattern: params.pattern,
+              path: params.path,
+              include: params.include,
+            },
+          })
 
           let searchPath = params.path ?? Instance.directory
           searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
@@ -171,7 +169,7 @@ export const GrepTool = Tool.defineEffect(
             },
             output: outputLines.join("\n"),
           }
-        }).pipe(Effect.orDie, Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 16 - 13
packages/opencode/src/tool/invalid.ts

@@ -1,17 +1,20 @@
 import z from "zod"
+import { Effect } from "effect"
 import { Tool } from "./tool"
 
-export const InvalidTool = Tool.define("invalid", {
-  description: "Do not use",
-  parameters: z.object({
-    tool: z.string(),
-    error: z.string(),
+export const InvalidTool = Tool.define(
+  "invalid",
+  Effect.succeed({
+    description: "Do not use",
+    parameters: z.object({
+      tool: z.string(),
+      error: z.string(),
+    }),
+    execute: (params: { tool: string; error: string }) =>
+      Effect.succeed({
+        title: "Invalid Tool",
+        output: `The arguments provided to the tool are invalid: ${params.error}`,
+        metadata: {},
+      }),
   }),
-  async execute(params) {
-    return {
-      title: "Invalid Tool",
-      output: `The arguments provided to the tool are invalid: ${params.error}`,
-      metadata: {},
-    }
-  },
-})
+)

+ 10 - 12
packages/opencode/src/tool/ls.ts

@@ -37,7 +37,7 @@ export const IGNORE_PATTERNS = [
 
 const LIMIT = 100
 
-export const ListTool = Tool.defineEffect(
+export const ListTool = Tool.define(
   "list",
   Effect.gen(function* () {
     const rg = yield* Ripgrep.Service
@@ -56,16 +56,14 @@ export const ListTool = Tool.defineEffect(
           const searchPath = path.resolve(Instance.directory, params.path || ".")
           yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
 
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "list",
-              patterns: [searchPath],
-              always: ["*"],
-              metadata: {
-                path: searchPath,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "list",
+            patterns: [searchPath],
+            always: ["*"],
+            metadata: {
+              path: searchPath,
+            },
+          })
 
           const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
           const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
@@ -130,7 +128,7 @@ export const ListTool = Tool.defineEffect(
             },
             output,
           }
-        }).pipe(Effect.orDie, Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

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

@@ -21,7 +21,7 @@ const operations = [
   "outgoingCalls",
 ] as const
 
-export const LspTool = Tool.defineEffect(
+export const LspTool = Tool.define(
   "lsp",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
@@ -42,7 +42,7 @@ export const LspTool = Tool.defineEffect(
         Effect.gen(function* () {
           const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
           yield* assertExternalDirectoryEffect(ctx, file)
-          yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
+          yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
 
           const uri = pathToFileURL(file).href
           const position = { file, line: args.line - 1, character: args.character - 1 }
@@ -85,7 +85,7 @@ export const LspTool = Tool.defineEffect(
             metadata: { result },
             output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
           }
-        }).pipe(Effect.runPromise),
+        }),
     }
   }),
 )

+ 10 - 12
packages/opencode/src/tool/multiedit.ts

@@ -6,7 +6,7 @@ import DESCRIPTION from "./multiedit.txt"
 import path from "path"
 import { Instance } from "../project/instance"
 
-export const MultiEditTool = Tool.defineEffect(
+export const MultiEditTool = Tool.define(
   "multiedit",
   Effect.gen(function* () {
     const editInfo = yield* EditTool
@@ -37,16 +37,14 @@ export const MultiEditTool = Tool.defineEffect(
         Effect.gen(function* () {
           const results = []
           for (const [, entry] of params.edits.entries()) {
-            const result = yield* Effect.promise(() =>
-              edit.execute(
-                {
-                  filePath: params.filePath,
-                  oldString: entry.oldString,
-                  newString: entry.newString,
-                  replaceAll: entry.replaceAll,
-                },
-                ctx,
-              ),
+            const result = yield* edit.execute(
+              {
+                filePath: params.filePath,
+                oldString: entry.oldString,
+                newString: entry.newString,
+                replaceAll: entry.replaceAll,
+              },
+              ctx,
             )
             results.push(result)
           }
@@ -57,7 +55,7 @@ export const MultiEditTool = Tool.defineEffect(
             },
             output: results.at(-1)!.output,
           }
-        }).pipe(Effect.orDie, Effect.runPromise),
+        }),
     }
   }),
 )

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

@@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) {
   return undefined
 }
 
-export const PlanExitTool = Tool.defineEffect(
+export const PlanExitTool = Tool.define(
   "plan_exit",
   Effect.gen(function* () {
     const session = yield* Session.Service
@@ -74,7 +74,7 @@ export const PlanExitTool = Tool.defineEffect(
             output: "User approved switching to build agent. Wait for further instructions.",
             metadata: {},
           }
-        }).pipe(Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

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

@@ -12,7 +12,7 @@ type Metadata = {
   answers: Question.Answer[]
 }
 
-export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
+export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
   "question",
   Effect.gen(function* () {
     const question = yield* Question.Service
@@ -39,7 +39,7 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
               answers,
             },
           }
-        }).pipe(Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 8 - 12
packages/opencode/src/tool/read.ts

@@ -25,7 +25,7 @@ const parameters = z.object({
   limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
 })
 
-export const ReadTool = Tool.defineEffect(
+export const ReadTool = Tool.define(
   "read",
   Effect.gen(function* () {
     const fs = yield* AppFileSystem.Service
@@ -106,14 +106,12 @@ export const ReadTool = Tool.defineEffect(
         kind: stat?.type === "Directory" ? "directory" : "file",
       })
 
-      yield* Effect.promise(() =>
-        ctx.ask({
-          permission: "read",
-          patterns: [filepath],
-          always: ["*"],
-          metadata: {},
-        }),
-      )
+      yield* ctx.ask({
+        permission: "read",
+        patterns: [filepath],
+        always: ["*"],
+        metadata: {},
+      })
 
       if (!stat) return yield* miss(filepath)
 
@@ -218,9 +216,7 @@ export const ReadTool = Tool.defineEffect(
     return {
       description: DESCRIPTION,
       parameters,
-      async execute(params: z.infer<typeof parameters>, ctx) {
-        return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
-      },
+      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
   }),
 )

+ 28 - 18
packages/opencode/src/tool/registry.ts

@@ -44,6 +44,7 @@ import { LSP } from "../lsp"
 import { FileTime } from "../file/time"
 import { Instruction } from "../session/instruction"
 import { AppFileSystem } from "../filesystem"
+import { Bus } from "../bus"
 import { Agent } from "../agent/agent"
 import { Skill } from "../skill"
 import { Permission } from "@/permission"
@@ -89,10 +90,12 @@ export namespace ToolRegistry {
     | FileTime.Service
     | Instruction.Service
     | AppFileSystem.Service
+    | Bus.Service
     | HttpClient.HttpClient
     | ChildProcessSpawner
     | Ripgrep.Service
     | Format.Service
+    | Truncate.Service
   > = Layer.effect(
     Service,
     Effect.gen(function* () {
@@ -100,7 +103,9 @@ export namespace ToolRegistry {
       const plugin = yield* Plugin.Service
       const agents = yield* Agent.Service
       const skill = yield* Skill.Service
+      const truncate = yield* Truncate.Service
 
+      const invalid = yield* InvalidTool
       const task = yield* TaskTool
       const read = yield* ReadTool
       const question = yield* QuestionTool
@@ -127,23 +132,26 @@ export namespace ToolRegistry {
               id,
               parameters: z.object(def.args),
               description: def.description,
-              execute: async (args, toolCtx) => {
-                const pluginCtx: PluginToolContext = {
-                  ...toolCtx,
-                  directory: ctx.directory,
-                  worktree: ctx.worktree,
-                }
-                const result = await def.execute(args as any, pluginCtx)
-                const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
-                return {
-                  title: "",
-                  output: out.truncated ? out.content : result,
-                  metadata: {
-                    truncated: out.truncated,
-                    outputPath: out.truncated ? out.outputPath : undefined,
-                  },
-                }
-              },
+              execute: (args, toolCtx) =>
+                Effect.gen(function* () {
+                  const pluginCtx: PluginToolContext = {
+                    ...toolCtx,
+                    ask: (req) => Effect.runPromise(toolCtx.ask(req)),
+                    directory: ctx.directory,
+                    worktree: ctx.worktree,
+                  }
+                  const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
+                  const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
+                  const out = yield* truncate.output(result, {}, agent)
+                  return {
+                    title: "",
+                    output: out.truncated ? out.content : result,
+                    metadata: {
+                      truncated: out.truncated,
+                      outputPath: out.truncated ? out.outputPath : undefined,
+                    },
+                  }
+                }),
             }
           }
 
@@ -174,7 +182,7 @@ export namespace ToolRegistry {
             ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
 
           const tool = yield* Effect.all({
-            invalid: Tool.init(InvalidTool),
+            invalid: Tool.init(invalid),
             bash: Tool.init(bash),
             read: Tool.init(read),
             glob: Tool.init(globtool),
@@ -328,10 +336,12 @@ export namespace ToolRegistry {
       Layer.provide(FileTime.defaultLayer),
       Layer.provide(Instruction.defaultLayer),
       Layer.provide(AppFileSystem.defaultLayer),
+      Layer.provide(Bus.layer),
       Layer.provide(FetchHttpClient.layer),
       Layer.provide(Format.defaultLayer),
       Layer.provide(CrossSpawnSpawner.defaultLayer),
       Layer.provide(Ripgrep.defaultLayer),
+      Layer.provide(Truncate.defaultLayer),
     ),
   )
 

+ 8 - 10
packages/opencode/src/tool/skill.ts

@@ -11,7 +11,7 @@ const Parameters = z.object({
   name: z.string().describe("The name of the skill from available_skills"),
 })
 
-export const SkillTool = Tool.defineEffect(
+export const SkillTool = Tool.define(
   "skill",
   Effect.gen(function* () {
     const skill = yield* Skill.Service
@@ -51,14 +51,12 @@ export const SkillTool = Tool.defineEffect(
               throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
             }
 
-            yield* Effect.promise(() =>
-              ctx.ask({
-                permission: "skill",
-                patterns: [params.name],
-                always: [params.name],
-                metadata: {},
-              }),
-            )
+            yield* ctx.ask({
+              permission: "skill",
+              patterns: [params.name],
+              always: [params.name],
+              metadata: {},
+            })
 
             const dir = path.dirname(info.location)
             const base = pathToFileURL(dir).href
@@ -94,7 +92,7 @@ export const SkillTool = Tool.defineEffect(
                 dir,
               },
             }
-          }).pipe(Effect.orDie, Effect.runPromise),
+          }).pipe(Effect.orDie),
       }
     }
   }),

+ 11 - 15
packages/opencode/src/tool/task.ts

@@ -31,7 +31,7 @@ const parameters = z.object({
   command: z.string().describe("The command that triggered this task").optional(),
 })
 
-export const TaskTool = Tool.defineEffect(
+export const TaskTool = Tool.define(
   id,
   Effect.gen(function* () {
     const agent = yield* Agent.Service
@@ -41,17 +41,15 @@ export const TaskTool = Tool.defineEffect(
       const cfg = yield* config.get()
 
       if (!ctx.extra?.bypassAgentCheck) {
-        yield* Effect.promise(() =>
-          ctx.ask({
-            permission: id,
-            patterns: [params.subagent_type],
-            always: ["*"],
-            metadata: {
-              description: params.description,
-              subagent_type: params.subagent_type,
-            },
-          }),
-        )
+        yield* ctx.ask({
+          permission: id,
+          patterns: [params.subagent_type],
+          always: ["*"],
+          metadata: {
+            description: params.description,
+            subagent_type: params.subagent_type,
+          },
+        })
       }
 
       const next = yield* agent.get(params.subagent_type)
@@ -178,9 +176,7 @@ export const TaskTool = Tool.defineEffect(
     return {
       description: DESCRIPTION,
       parameters,
-      async execute(params: z.infer<typeof parameters>, ctx) {
-        return Effect.runPromise(run(params, ctx))
-      },
+      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
     }
   }),
 )

+ 18 - 19
packages/opencode/src/tool/todo.ts

@@ -12,7 +12,7 @@ type Metadata = {
   todos: Todo.Info[]
 }
 
-export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
+export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
   "todowrite",
   Effect.gen(function* () {
     const todo = yield* Todo.Service
@@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
     return {
       description: DESCRIPTION_WRITE,
       parameters,
-      async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
-        await ctx.ask({
-          permission: "todowrite",
-          patterns: ["*"],
-          always: ["*"],
-          metadata: {},
-        })
+      execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
+        Effect.gen(function* () {
+          yield* ctx.ask({
+            permission: "todowrite",
+            patterns: ["*"],
+            always: ["*"],
+            metadata: {},
+          })
 
-        await todo
-          .update({
+          yield* todo.update({
             sessionID: ctx.sessionID,
             todos: params.todos,
           })
-          .pipe(Effect.runPromise)
 
-        return {
-          title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
-          output: JSON.stringify(params.todos, null, 2),
-          metadata: {
-            todos: params.todos,
-          },
-        }
-      },
+          return {
+            title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
+            output: JSON.stringify(params.todos, null, 2),
+            metadata: {
+              todos: params.todos,
+            },
+          }
+        }),
     } satisfies Tool.DefWithoutID<typeof parameters, Metadata>
   }),
 )

+ 39 - 47
packages/opencode/src/tool/tool.ts

@@ -23,22 +23,21 @@ export namespace Tool {
     extra?: { [key: string]: any }
     messages: MessageV2.WithParts[]
     metadata(input: { title?: string; metadata?: M }): void
-    ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
+    ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
+  }
+
+  export interface ExecuteResult<M extends Metadata = Metadata> {
+    title: string
+    metadata: M
+    output: string
+    attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
   }
 
   export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
     id: string
     description: string
     parameters: Parameters
-    execute(
-      args: z.infer<Parameters>,
-      ctx: Context,
-    ): Promise<{
-      title: string
-      metadata: M
-      output: string
-      attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
-    }>
+    execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
     formatValidationError?(error: z.ZodError): string
   }
   export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
@@ -74,48 +73,41 @@ export namespace Tool {
     return async () => {
       const toolInfo = init instanceof Function ? await init() : { ...init }
       const execute = toolInfo.execute
-      toolInfo.execute = async (args, ctx) => {
-        try {
-          toolInfo.parameters.parse(args)
-        } catch (error) {
-          if (error instanceof z.ZodError && toolInfo.formatValidationError) {
-            throw new Error(toolInfo.formatValidationError(error), { cause: error })
+      toolInfo.execute = (args, ctx) =>
+        Effect.gen(function* () {
+          yield* Effect.try({
+            try: () => toolInfo.parameters.parse(args),
+            catch: (error) => {
+              if (error instanceof z.ZodError && toolInfo.formatValidationError) {
+                return new Error(toolInfo.formatValidationError(error), { cause: error })
+              }
+              return new Error(
+                `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
+                { cause: error },
+              )
+            },
+          })
+          const result = yield* execute(args, ctx)
+          if (result.metadata.truncated !== undefined) {
+            return result
           }
-          throw new Error(
-            `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
-            { cause: error },
-          )
-        }
-        const result = await execute(args, ctx)
-        if (result.metadata.truncated !== undefined) {
-          return result
-        }
-        const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
-        return {
-          ...result,
-          output: truncated.content,
-          metadata: {
-            ...result.metadata,
-            truncated: truncated.truncated,
-            ...(truncated.truncated && { outputPath: truncated.outputPath }),
-          },
-        }
-      }
+          const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
+          const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
+          return {
+            ...result,
+            output: truncated.content,
+            metadata: {
+              ...result.metadata,
+              truncated: truncated.truncated,
+              ...(truncated.truncated && { outputPath: truncated.outputPath }),
+            },
+          }
+        }).pipe(Effect.orDie)
       return toolInfo
     }
   }
 
-  export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
-    id: ID,
-    init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
-  ): Info<Parameters, Result> & { id: ID } {
-    return {
-      id,
-      init: wrap(id, init),
-    }
-  }
-
-  export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
+  export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
     id: ID,
     init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
   ): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {

+ 12 - 14
packages/opencode/src/tool/webfetch.ts

@@ -18,7 +18,7 @@ const parameters = z.object({
   timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
 })
 
-export const WebFetchTool = Tool.defineEffect(
+export const WebFetchTool = Tool.define(
   "webfetch",
   Effect.gen(function* () {
     const http = yield* HttpClient.HttpClient
@@ -33,18 +33,16 @@ export const WebFetchTool = Tool.defineEffect(
             throw new Error("URL must start with http:// or https://")
           }
 
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "webfetch",
-              patterns: [params.url],
-              always: ["*"],
-              metadata: {
-                url: params.url,
-                format: params.format,
-                timeout: params.timeout,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "webfetch",
+            patterns: [params.url],
+            always: ["*"],
+            metadata: {
+              url: params.url,
+              format: params.format,
+              timeout: params.timeout,
+            },
+          })
 
           const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
 
@@ -153,7 +151,7 @@ export const WebFetchTool = Tool.defineEffect(
             default:
               return { output: content, title, metadata: {} }
           }
-        }).pipe(Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 14 - 16
packages/opencode/src/tool/websearch.ts

@@ -24,7 +24,7 @@ const Parameters = z.object({
     .describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
 })
 
-export const WebSearchTool = Tool.defineEffect(
+export const WebSearchTool = Tool.define(
   "websearch",
   Effect.gen(function* () {
     const http = yield* HttpClient.HttpClient
@@ -36,20 +36,18 @@ export const WebSearchTool = Tool.defineEffect(
       parameters: Parameters,
       execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
         Effect.gen(function* () {
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "websearch",
-              patterns: [params.query],
-              always: ["*"],
-              metadata: {
-                query: params.query,
-                numResults: params.numResults,
-                livecrawl: params.livecrawl,
-                type: params.type,
-                contextMaxCharacters: params.contextMaxCharacters,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "websearch",
+            patterns: [params.query],
+            always: ["*"],
+            metadata: {
+              query: params.query,
+              numResults: params.numResults,
+              livecrawl: params.livecrawl,
+              type: params.type,
+              contextMaxCharacters: params.contextMaxCharacters,
+            },
+          })
 
           const result = yield* McpExa.call(
             http,
@@ -70,7 +68,7 @@ export const WebSearchTool = Tool.defineEffect(
             title: `Web search: ${params.query}`,
             metadata: {},
           }
-        }).pipe(Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 19 - 21
packages/opencode/src/tool/write.ts

@@ -17,12 +17,14 @@ import { assertExternalDirectoryEffect } from "./external-directory"
 
 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
 
-export const WriteTool = Tool.defineEffect(
+export const WriteTool = Tool.define(
   "write",
   Effect.gen(function* () {
     const lsp = yield* LSP.Service
     const fs = yield* AppFileSystem.Service
     const filetime = yield* FileTime.Service
+    const bus = yield* Bus.Service
+    const format = yield* Format.Service
 
     return {
       description: DESCRIPTION,
@@ -42,27 +44,23 @@ export const WriteTool = Tool.defineEffect(
           if (exists) yield* filetime.assert(ctx.sessionID, filepath)
 
           const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
-          yield* Effect.promise(() =>
-            ctx.ask({
-              permission: "edit",
-              patterns: [path.relative(Instance.worktree, filepath)],
-              always: ["*"],
-              metadata: {
-                filepath,
-                diff,
-              },
-            }),
-          )
+          yield* ctx.ask({
+            permission: "edit",
+            patterns: [path.relative(Instance.worktree, filepath)],
+            always: ["*"],
+            metadata: {
+              filepath,
+              diff,
+            },
+          })
 
           yield* fs.writeWithDirs(filepath, params.content)
-          yield* Effect.promise(() => Format.file(filepath))
-          Bus.publish(File.Event.Edited, { file: filepath })
-          yield* Effect.promise(() =>
-            Bus.publish(FileWatcher.Event.Updated, {
-              file: filepath,
-              event: exists ? "change" : "add",
-            }),
-          )
+          yield* format.file(filepath)
+          yield* bus.publish(File.Event.Edited, { file: filepath })
+          yield* bus.publish(FileWatcher.Event.Updated, {
+            file: filepath,
+            event: exists ? "change" : "add",
+          })
           yield* filetime.read(ctx.sessionID, filepath)
 
           let output = "Wrote file successfully."
@@ -92,7 +90,7 @@ export const WriteTool = Tool.defineEffect(
             },
             output,
           }
-        }).pipe(Effect.orDie, Effect.runPromise),
+        }).pipe(Effect.orDie),
     }
   }),
 )

+ 9 - 17
packages/opencode/test/session/prompt-effect.test.ts

@@ -144,7 +144,7 @@ const filetime = Layer.succeed(
     read: () => Effect.void,
     get: () => Effect.succeed(undefined),
     assert: () => Effect.void,
-    withLock: (_filepath, fn) => Effect.promise(fn),
+    withLock: (_filepath, fn) => fn(),
   }),
 )
 
@@ -735,19 +735,12 @@ it.live(
           const registry = yield* ToolRegistry.Service
           const { task } = yield* registry.named()
           const original = task.execute
-          task.execute = async (_args, ctx) => {
-            ready.resolve()
-            ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
-            await new Promise<void>(() => {})
-            return {
-              title: "",
-              metadata: {
-                sessionId: SessionID.make("task"),
-                model: ref,
-              },
-              output: "",
-            }
-          }
+          task.execute = (_args, ctx) =>
+            Effect.callback<never>((resume) => {
+              ready.resolve()
+              ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
+              return Effect.sync(() => aborted.resolve())
+            })
           yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
 
           const { prompt, chat } = yield* boot()
@@ -1393,11 +1386,10 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
   const ready = defer<void>()
   const aborted = defer<void>()
   const original = tool.execute
-  tool.execute = async (_args: any, ctx: any) => {
+  tool.execute = (_args: any, ctx: any) => {
     ready.resolve()
     ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
-    await new Promise<void>(() => {})
-    return { title: "", metadata: {}, output: "" }
+    return Effect.callback<never>(() => {})
   }
   const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
   return { ready, aborted, restore }

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

@@ -107,7 +107,7 @@ const filetime = Layer.succeed(
     read: () => Effect.void,
     get: () => Effect.succeed(undefined),
     assert: () => Effect.void,
-    withLock: (_filepath, fn) => Effect.promise(fn),
+    withLock: (_filepath, fn) => fn(),
   }),
 )
 

+ 6 - 6
packages/opencode/test/tool/apply_patch.test.ts

@@ -7,10 +7,11 @@ import { Instance } from "../../src/project/instance"
 import { LSP } from "../../src/lsp"
 import { AppFileSystem } from "../../src/filesystem"
 import { Format } from "../../src/format"
+import { Bus } from "../../src/bus"
 import { tmpdir } from "../fixture/fixture"
 import { SessionID, MessageID } from "../../src/session/schema"
 
-const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
+const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer))
 
 const baseCtx = {
   sessionID: SessionID.make("ses_test"),
@@ -42,22 +43,21 @@ type AskInput = {
 }
 
 type ToolCtx = typeof baseCtx & {
-  ask: (input: AskInput) => Promise<void>
+  ask: (input: AskInput) => Effect.Effect<void>
 }
 
 const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
   const info = await runtime.runPromise(ApplyPatchTool)
   const tool = await info.init()
-  return tool.execute(params, ctx)
+  return Effect.runPromise(tool.execute(params, ctx))
 }
 
 const makeCtx = () => {
   const calls: AskInput[] = []
   const ctx: ToolCtx = {
     ...baseCtx,
-    ask: async (input) => {
-      calls.push(input)
-    },
+    ask: (input) =>
+      Effect.sync(() => { calls.push(input) }),
   }
 
   return { ctx, calls }

+ 81 - 81
packages/opencode/test/tool/bash.test.ts

@@ -30,7 +30,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 Shell.acceptable.reset()
@@ -109,10 +109,11 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom
 
 const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
   ...ctx,
-  ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
-    requests.push(req)
-    if (stop) throw stop
-  },
+  ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+    Effect.sync(() => {
+      requests.push(req)
+      if (stop) throw stop
+    }),
 })
 
 const mustTruncate = (result: {
@@ -131,13 +132,13 @@ describe("tool.bash", () => {
       directory: projectRoot,
       fn: async () => {
         const bash = await initBash()
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: "echo test",
             description: "Echo test message",
           },
           ctx,
-        )
+        ))
         expect(result.metadata.exit).toBe(0)
         expect(result.metadata.output).toContain("test")
       },
@@ -153,13 +154,13 @@ describe("tool.bash permissions", () => {
       fn: async () => {
         const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-        await bash.execute(
+        await Effect.runPromise(bash.execute(
           {
             command: "echo hello",
             description: "Echo hello",
           },
           capture(requests),
-        )
+        ))
         expect(requests.length).toBe(1)
         expect(requests[0].permission).toBe("bash")
         expect(requests[0].patterns).toContain("echo hello")
@@ -174,13 +175,13 @@ describe("tool.bash permissions", () => {
       fn: async () => {
         const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-        await bash.execute(
+        await Effect.runPromise(bash.execute(
           {
             command: "echo foo && echo bar",
             description: "Echo twice",
           },
           capture(requests),
-        )
+        ))
         expect(requests.length).toBe(1)
         expect(requests[0].permission).toBe("bash")
         expect(requests[0].patterns).toContain("echo foo")
@@ -198,13 +199,13 @@ describe("tool.bash permissions", () => {
           fn: async () => {
             const bash = await initBash()
             const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-            await bash.execute(
+            await Effect.runPromise(bash.execute(
               {
                 command: "Write-Host foo; if ($?) { Write-Host bar }",
                 description: "Check PowerShell conditional",
               },
               capture(requests),
-            )
+            ))
             const bashReq = requests.find((r) => r.permission === "bash")
             expect(bashReq).toBeDefined()
             expect(bashReq!.patterns).toContain("Write-Host foo")
@@ -226,13 +227,13 @@ describe("tool.bash permissions", () => {
         const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
         const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
         await expect(
-          bash.execute(
+          Effect.runPromise(bash.execute(
             {
               command: `cat ${file}`,
               description: "Read wildcard path",
             },
             capture(requests, err),
-          ),
+          )),
         ).rejects.toThrow(err.message)
         const extDirReq = requests.find((r) => r.permission === "external_directory")
         expect(extDirReq).toBeDefined()
@@ -257,13 +258,13 @@ describe("tool.bash permissions", () => {
               const bash = await initBash()
               const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-              await bash.execute(
+              await Effect.runPromise(bash.execute(
                 {
                   command: `echo $(cat "${file}")`,
                   description: "Read nested bash file",
                 },
                 capture(requests),
-              )
+              ))
               const extDirReq = requests.find((r) => r.permission === "external_directory")
               const bashReq = requests.find((r) => r.permission === "bash")
               expect(extDirReq).toBeDefined()
@@ -289,13 +290,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
                     description: "Copy Windows ini",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               const extDirReq = requests.find((r) => r.permission === "external_directory")
               expect(extDirReq).toBeDefined()
@@ -316,13 +317,13 @@ describe("tool.bash permissions", () => {
               const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
-              await bash.execute(
+              await Effect.runPromise(bash.execute(
                 {
                   command: `Write-Output $(Get-Content ${file})`,
                   description: "Read nested PowerShell file",
                 },
                 capture(requests),
-              )
+              ))
               const extDirReq = requests.find((r) => r.permission === "external_directory")
               const bashReq = requests.find((r) => r.permission === "bash")
               expect(extDirReq).toBeDefined()
@@ -347,13 +348,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: 'Get-Content "C:../outside.txt"',
                     description: "Read drive-relative file",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]?.permission).toBe("external_directory")
               if (requests[0]?.permission !== "external_directory") return
@@ -375,13 +376,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: 'Get-Content "$HOME/.ssh/config"',
                     description: "Read home config",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]?.permission).toBe("external_directory")
               if (requests[0]?.permission !== "external_directory") return
@@ -404,13 +405,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: 'Get-Content "$PWD/../outside.txt"',
                     description: "Read pwd-relative file",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]?.permission).toBe("external_directory")
               if (requests[0]?.permission !== "external_directory") return
@@ -432,13 +433,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: 'Get-Content "$PSHOME/outside.txt"',
                     description: "Read pshome file",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]?.permission).toBe("external_directory")
               if (requests[0]?.permission !== "external_directory") return
@@ -465,13 +466,13 @@ describe("tool.bash permissions", () => {
                 const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
                 const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
                 await expect(
-                  bash.execute(
+                  Effect.runPromise(bash.execute(
                     {
                       command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
                       description: "Read Windows ini with missing env",
                     },
                     capture(requests, err),
-                  ),
+                  )),
                 ).rejects.toThrow(err.message)
                 const extDirReq = requests.find((r) => r.permission === "external_directory")
                 expect(extDirReq).toBeDefined()
@@ -495,13 +496,13 @@ describe("tool.bash permissions", () => {
             fn: async () => {
               const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-              await bash.execute(
+              await Effect.runPromise(bash.execute(
                 {
                   command: "Get-Content $env:WINDIR/win.ini",
                   description: "Read Windows ini from env",
                 },
                 capture(requests),
-              )
+              ))
               const extDirReq = requests.find((r) => r.permission === "external_directory")
               expect(extDirReq).toBeDefined()
               expect(extDirReq!.patterns).toContain(
@@ -524,13 +525,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
                     description: "Read Windows ini from FileSystem provider",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]?.permission).toBe("external_directory")
               if (requests[0]?.permission !== "external_directory") return
@@ -554,13 +555,13 @@ describe("tool.bash permissions", () => {
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: "Get-Content ${env:WINDIR}/win.ini",
                     description: "Read Windows ini from braced env",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]?.permission).toBe("external_directory")
               if (requests[0]?.permission !== "external_directory") return
@@ -582,13 +583,13 @@ describe("tool.bash permissions", () => {
             fn: async () => {
               const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-              await bash.execute(
+              await Effect.runPromise(bash.execute(
                 {
                   command: "Set-Location C:/Windows",
                   description: "Change location",
                 },
                 capture(requests),
-              )
+              ))
               const extDirReq = requests.find((r) => r.permission === "external_directory")
               const bashReq = requests.find((r) => r.permission === "bash")
               expect(extDirReq).toBeDefined()
@@ -611,13 +612,13 @@ describe("tool.bash permissions", () => {
             fn: async () => {
               const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-              await bash.execute(
+              await Effect.runPromise(bash.execute(
                 {
                   command: "Write-Output ('a' * 3)",
                   description: "Write repeated text",
                 },
                 capture(requests),
-              )
+              ))
               const bashReq = requests.find((r) => r.permission === "bash")
               expect(bashReq).toBeDefined()
               expect(bashReq!.patterns).not.toContain("a * 3")
@@ -638,13 +639,13 @@ describe("tool.bash permissions", () => {
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await expect(
-          bash.execute(
+          Effect.runPromise(bash.execute(
             {
               command: "cd ../",
               description: "Change to parent directory",
             },
             capture(requests, err),
-          ),
+          )),
         ).rejects.toThrow(err.message)
         const extDirReq = requests.find((r) => r.permission === "external_directory")
         expect(extDirReq).toBeDefined()
@@ -661,14 +662,14 @@ describe("tool.bash permissions", () => {
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await expect(
-          bash.execute(
+          Effect.runPromise(bash.execute(
             {
               command: "echo ok",
               workdir: os.tmpdir(),
               description: "Echo from temp dir",
             },
             capture(requests, err),
-          ),
+          )),
         ).rejects.toThrow(err.message)
         const extDirReq = requests.find((r) => r.permission === "external_directory")
         expect(extDirReq).toBeDefined()
@@ -691,14 +692,14 @@ describe("tool.bash permissions", () => {
           for (const dir of forms(outerTmp.path)) {
             const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
             await expect(
-              bash.execute(
+              Effect.runPromise(bash.execute(
                 {
                   command: "echo ok",
                   workdir: dir,
                   description: "Echo from external dir",
                 },
                 capture(requests, err),
-              ),
+              )),
             ).rejects.toThrow(err.message)
 
             const extDirReq = requests.find((r) => r.permission === "external_directory")
@@ -724,14 +725,14 @@ describe("tool.bash permissions", () => {
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               const want = glob(path.join(os.tmpdir(), "*"))
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: "echo ok",
                     workdir: "/tmp",
                     description: "Echo from Git Bash tmp",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]).toMatchObject({
                 permission: "external_directory",
@@ -754,13 +755,13 @@ describe("tool.bash permissions", () => {
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               const want = glob(path.join(os.tmpdir(), "*"))
               await expect(
-                bash.execute(
+                Effect.runPromise(bash.execute(
                   {
                     command: "cat /tmp/opencode-does-not-exist",
                     description: "Read Git Bash tmp file",
                   },
                   capture(requests, err),
-                ),
+                )),
               ).rejects.toThrow(err.message)
               expect(requests[0]).toMatchObject({
                 permission: "external_directory",
@@ -789,13 +790,13 @@ describe("tool.bash permissions", () => {
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const filepath = path.join(outerTmp.path, "outside.txt")
         await expect(
-          bash.execute(
+          Effect.runPromise(bash.execute(
             {
               command: `cat ${filepath}`,
               description: "Read external file",
             },
             capture(requests, err),
-          ),
+          )),
         ).rejects.toThrow(err.message)
         const extDirReq = requests.find((r) => r.permission === "external_directory")
         const expected = glob(path.join(outerTmp.path, "*"))
@@ -817,13 +818,13 @@ describe("tool.bash permissions", () => {
       fn: async () => {
         const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-        await bash.execute(
+        await Effect.runPromise(bash.execute(
           {
             command: `rm -rf ${path.join(tmp.path, "nested")}`,
             description: "Remove nested dir",
           },
           capture(requests),
-        )
+        ))
         const extDirReq = requests.find((r) => r.permission === "external_directory")
         expect(extDirReq).toBeUndefined()
       },
@@ -837,13 +838,13 @@ describe("tool.bash permissions", () => {
       fn: async () => {
         const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-        await bash.execute(
+        await Effect.runPromise(bash.execute(
           {
             command: "git log --oneline -5",
             description: "Git log",
           },
           capture(requests),
-        )
+        ))
         expect(requests.length).toBe(1)
         expect(requests[0].always.length).toBeGreaterThan(0)
         expect(requests[0].always.some((item) => item.endsWith("*"))).toBe(true)
@@ -858,13 +859,13 @@ describe("tool.bash permissions", () => {
       fn: async () => {
         const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-        await bash.execute(
+        await Effect.runPromise(bash.execute(
           {
             command: "cd .",
             description: "Stay in current directory",
           },
           capture(requests),
-        )
+        ))
         const bashReq = requests.find((r) => r.permission === "bash")
         expect(bashReq).toBeUndefined()
       },
@@ -880,10 +881,10 @@ describe("tool.bash permissions", () => {
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await expect(
-          bash.execute(
+          Effect.runPromise(bash.execute(
             { command: "echo test > output.txt", description: "Redirect test output" },
             capture(requests, err),
-          ),
+          )),
         ).rejects.toThrow(err.message)
         const bashReq = requests.find((r) => r.permission === "bash")
         expect(bashReq).toBeDefined()
@@ -899,7 +900,7 @@ describe("tool.bash permissions", () => {
       fn: async () => {
         const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-        await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
+        await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
         const bashReq = requests.find((r) => r.permission === "bash")
         expect(bashReq).toBeDefined()
         expect(bashReq!.always[0]).toBe("ls *")
@@ -916,7 +917,7 @@ describe("tool.bash abort", () => {
         const bash = await initBash()
         const controller = new AbortController()
         const collected: string[] = []
-        const result = bash.execute(
+        const res = await Effect.runPromise(bash.execute(
           {
             command: `echo before && sleep 30`,
             description: "Long running command",
@@ -932,8 +933,7 @@ describe("tool.bash abort", () => {
               }
             },
           },
-        )
-        const res = await result
+        ))
         expect(res.output).toContain("before")
         expect(res.output).toContain("User aborted the command")
         expect(collected.length).toBeGreaterThan(0)
@@ -946,14 +946,14 @@ describe("tool.bash abort", () => {
       directory: projectRoot,
       fn: async () => {
         const bash = await initBash()
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: `echo started && sleep 60`,
             description: "Timeout test",
             timeout: 500,
           },
           ctx,
-        )
+        ))
         expect(result.output).toContain("started")
         expect(result.output).toContain("bash tool terminated command after exceeding timeout")
       },
@@ -965,13 +965,13 @@ describe("tool.bash abort", () => {
       directory: projectRoot,
       fn: async () => {
         const bash = await initBash()
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: `echo stdout_msg && echo stderr_msg >&2`,
             description: "Stderr test",
           },
           ctx,
-        )
+        ))
         expect(result.output).toContain("stdout_msg")
         expect(result.output).toContain("stderr_msg")
         expect(result.metadata.exit).toBe(0)
@@ -984,13 +984,13 @@ describe("tool.bash abort", () => {
       directory: projectRoot,
       fn: async () => {
         const bash = await initBash()
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: `exit 42`,
             description: "Non-zero exit",
           },
           ctx,
-        )
+        ))
         expect(result.metadata.exit).toBe(42)
       },
     })
@@ -1002,7 +1002,7 @@ describe("tool.bash abort", () => {
       fn: async () => {
         const bash = await initBash()
         const updates: string[] = []
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: `echo first && sleep 0.1 && echo second`,
             description: "Streaming test",
@@ -1014,7 +1014,7 @@ describe("tool.bash abort", () => {
               if (output) updates.push(output)
             },
           },
-        )
+        ))
         expect(result.output).toContain("first")
         expect(result.output).toContain("second")
         expect(updates.length).toBeGreaterThan(1)
@@ -1030,13 +1030,13 @@ describe("tool.bash truncation", () => {
       fn: async () => {
         const bash = await initBash()
         const lineCount = Truncate.MAX_LINES + 500
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: fill("lines", lineCount),
             description: "Generate lines exceeding limit",
           },
           ctx,
-        )
+        ))
         mustTruncate(result)
         expect(result.output).toContain("truncated")
         expect(result.output).toContain("The tool call succeeded but the output was truncated")
@@ -1050,13 +1050,13 @@ describe("tool.bash truncation", () => {
       fn: async () => {
         const bash = await initBash()
         const byteCount = Truncate.MAX_BYTES + 10000
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: fill("bytes", byteCount),
             description: "Generate bytes exceeding limit",
           },
           ctx,
-        )
+        ))
         mustTruncate(result)
         expect(result.output).toContain("truncated")
         expect(result.output).toContain("The tool call succeeded but the output was truncated")
@@ -1069,13 +1069,13 @@ describe("tool.bash truncation", () => {
       directory: projectRoot,
       fn: async () => {
         const bash = await initBash()
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: "echo hello",
             description: "Echo hello",
           },
           ctx,
-        )
+        ))
         expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
         expect(result.output).toContain("hello")
       },
@@ -1088,13 +1088,13 @@ describe("tool.bash truncation", () => {
       fn: async () => {
         const bash = await initBash()
         const lineCount = Truncate.MAX_LINES + 100
-        const result = await bash.execute(
+        const result = await Effect.runPromise(bash.execute(
           {
             command: fill("lines", lineCount),
             description: "Generate lines for file check",
           },
           ctx,
-        )
+        ))
         mustTruncate(result)
 
         const filepath = (result.metadata as { outputPath?: string }).outputPath

+ 67 - 58
packages/opencode/test/tool/edit.test.ts

@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 import { FileTime } from "../../src/file/time"
 import { LSP } from "../../src/lsp"
+import { AppFileSystem } from "../../src/filesystem"
+import { Format } from "../../src/format"
+import { Bus } from "../../src/bus"
+import { BusEvent } from "../../src/bus/bus-event"
 import { SessionID, MessageID } from "../../src/session/schema"
 
 const ctx = {
@@ -17,7 +21,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 afterEach(async () => {
@@ -29,7 +33,9 @@ async function touch(file: string, time: number) {
   await fs.utimes(file, date, date)
 }
 
-const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer))
+const runtime = ManagedRuntime.make(
+  Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
+)
 
 afterAll(async () => {
   await runtime.dispose()
@@ -43,6 +49,12 @@ const resolve = () =>
     }),
   )
 
+const readFileTime = (sessionID: SessionID, filepath: string) =>
+  runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
+
+const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
+  runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
+
 describe("tool.edit", () => {
   describe("creating new files", () => {
     test("creates new file when oldString is empty", async () => {
@@ -53,14 +65,14 @@ describe("tool.edit", () => {
         directory: tmp.path,
         fn: async () => {
           const edit = await resolve()
-          const result = await edit.execute(
+          const result = await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "",
               newString: "new content",
             },
             ctx,
-          )
+          ))
 
           expect(result.metadata.diff).toContain("new content")
 
@@ -78,14 +90,14 @@ describe("tool.edit", () => {
         directory: tmp.path,
         fn: async () => {
           const edit = await resolve()
-          await edit.execute(
+          await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "",
               newString: "nested file",
             },
             ctx,
-          )
+          ))
 
           const content = await fs.readFile(filepath, "utf-8")
           expect(content).toBe("nested file")
@@ -100,22 +112,20 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const { Bus } = await import("../../src/bus")
-          const { File } = await import("../../src/file")
           const { FileWatcher } = await import("../../src/file/watcher")
 
           const events: string[] = []
-          const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
+          const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
 
           const edit = await resolve()
-          await edit.execute(
+          await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "",
               newString: "content",
             },
             ctx,
-          )
+          ))
 
           expect(events).toContain("updated")
           unsubUpdated()
@@ -133,17 +143,17 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
-          const result = await edit.execute(
+          const result = await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "old content",
               newString: "new content",
             },
             ctx,
-          )
+          ))
 
           expect(result.output).toContain("Edit applied successfully")
 
@@ -160,18 +170,18 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: filepath,
                 oldString: "old",
                 newString: "new",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow("not found")
         },
       })
@@ -187,14 +197,14 @@ describe("tool.edit", () => {
         fn: async () => {
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: filepath,
                 oldString: "same",
                 newString: "same",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow("identical")
         },
       })
@@ -208,18 +218,18 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: filepath,
                 oldString: "not in file",
                 newString: "replacement",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow()
         },
       })
@@ -235,14 +245,14 @@ describe("tool.edit", () => {
         fn: async () => {
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: filepath,
                 oldString: "content",
                 newString: "modified",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow("You must read file")
         },
       })
@@ -258,7 +268,7 @@ describe("tool.edit", () => {
         directory: tmp.path,
         fn: async () => {
           // Read first
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           // Simulate external modification
           await fs.writeFile(filepath, "modified externally", "utf-8")
@@ -267,14 +277,14 @@ describe("tool.edit", () => {
           // Try to edit with the new content
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: filepath,
                 oldString: "modified externally",
                 newString: "edited",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow("modified since it was last read")
         },
       })
@@ -288,10 +298,10 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
-          await edit.execute(
+          await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "foo",
@@ -299,7 +309,7 @@ describe("tool.edit", () => {
               replaceAll: true,
             },
             ctx,
-          )
+          ))
 
           const content = await fs.readFile(filepath, "utf-8")
           expect(content).toBe("qux bar qux baz qux")
@@ -315,23 +325,22 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
-          const { Bus } = await import("../../src/bus")
           const { FileWatcher } = await import("../../src/file/watcher")
 
           const events: string[] = []
-          const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
+          const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
 
           const edit = await resolve()
-          await edit.execute(
+          await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "original",
               newString: "modified",
             },
             ctx,
-          )
+          ))
 
           expect(events).toContain("updated")
           unsubUpdated()
@@ -349,17 +358,17 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
-          await edit.execute(
+          await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "line2",
               newString: "new line 2\nextra line",
             },
             ctx,
-          )
+          ))
 
           const content = await fs.readFile(filepath, "utf-8")
           expect(content).toBe("line1\nnew line 2\nextra line\nline3")
@@ -375,17 +384,17 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
-          await edit.execute(
+          await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "old",
               newString: "new",
             },
             ctx,
-          )
+          ))
 
           const content = await fs.readFile(filepath, "utf-8")
           expect(content).toBe("line1\r\nnew\r\nline3")
@@ -403,14 +412,14 @@ describe("tool.edit", () => {
         fn: async () => {
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: filepath,
                 oldString: "",
                 newString: "",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow("identical")
         },
       })
@@ -424,18 +433,18 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, dirpath)
+          await readFileTime(ctx.sessionID, dirpath)
 
           const edit = await resolve()
           await expect(
-            edit.execute(
+            Effect.runPromise(edit.execute(
               {
                 filePath: dirpath,
                 oldString: "old",
                 newString: "new",
               },
               ctx,
-            ),
+            )),
           ).rejects.toThrow("directory")
         },
       })
@@ -449,17 +458,17 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
-          const result = await edit.execute(
+          const result = await Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "line2",
               newString: "new line a\nnew line b",
             },
             ctx,
-          )
+          ))
 
           expect(result.metadata.filediff).toBeDefined()
           expect(result.metadata.filediff.file).toBe(filepath)
@@ -520,8 +529,8 @@ describe("tool.edit", () => {
         fn: async () => {
           const edit = await resolve()
           const filePath = path.join(tmp.path, "test.txt")
-          await FileTime.read(ctx.sessionID, filePath)
-          await edit.execute(
+          await readFileTime(ctx.sessionID, filePath)
+          await Effect.runPromise(edit.execute(
             {
               filePath,
               oldString: input.oldString,
@@ -529,7 +538,7 @@ describe("tool.edit", () => {
               replaceAll: input.replaceAll,
             },
             ctx,
-          )
+          ))
           return await Bun.file(filePath).text()
         },
       })
@@ -661,31 +670,31 @@ describe("tool.edit", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
           const edit = await resolve()
 
           // Two concurrent edits
-          const promise1 = edit.execute(
+          const promise1 = Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "0",
               newString: "1",
             },
             ctx,
-          )
+          ))
 
           // Need to read again since FileTime tracks per-session
-          await FileTime.read(ctx.sessionID, filepath)
+          await readFileTime(ctx.sessionID, filepath)
 
-          const promise2 = edit.execute(
+          const promise2 = Effect.runPromise(edit.execute(
             {
               filePath: filepath,
               oldString: "0",
               newString: "2",
             },
             ctx,
-          )
+          ))
 
           // Both should complete without error (though one might fail due to content mismatch)
           const results = await Promise.allSettled([promise1, promise2])

+ 17 - 49
packages/opencode/test/tool/external-directory.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
+import { Effect } from "effect"
 import type { Tool } from "../../src/tool/tool"
 import { Instance } from "../../src/project/instance"
 import { assertExternalDirectory } from "../../src/tool/external-directory"
@@ -21,15 +22,18 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
 const glob = (p: string) =>
   process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
 
+function makeCtx() {
+  const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
+  const ctx: Tool.Context = {
+    ...baseCtx,
+    ask: (req) => Effect.sync(() => { requests.push(req) }),
+  }
+  return { requests, ctx }
+}
+
 describe("tool.assertExternalDirectory", () => {
   test("no-ops for empty target", async () => {
-    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-    const ctx: Tool.Context = {
-      ...baseCtx,
-      ask: async (req) => {
-        requests.push(req)
-      },
-    }
+    const { requests, ctx } = makeCtx()
 
     await Instance.provide({
       directory: "/tmp",
@@ -42,13 +46,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("no-ops for paths inside Instance.directory", async () => {
-    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-    const ctx: Tool.Context = {
-      ...baseCtx,
-      ask: async (req) => {
-        requests.push(req)
-      },
-    }
+    const { requests, ctx } = makeCtx()
 
     await Instance.provide({
       directory: "/tmp/project",
@@ -61,13 +59,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("asks with a single canonical glob", async () => {
-    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-    const ctx: Tool.Context = {
-      ...baseCtx,
-      ask: async (req) => {
-        requests.push(req)
-      },
-    }
+    const { requests, ctx } = makeCtx()
 
     const directory = "/tmp/project"
     const target = "/tmp/outside/file.txt"
@@ -87,13 +79,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("uses target directory when kind=directory", async () => {
-    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-    const ctx: Tool.Context = {
-      ...baseCtx,
-      ask: async (req) => {
-        requests.push(req)
-      },
-    }
+    const { requests, ctx } = makeCtx()
 
     const directory = "/tmp/project"
     const target = "/tmp/outside"
@@ -113,13 +99,7 @@ describe("tool.assertExternalDirectory", () => {
   })
 
   test("skips prompting when bypass=true", async () => {
-    const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-    const ctx: Tool.Context = {
-      ...baseCtx,
-      ask: async (req) => {
-        requests.push(req)
-      },
-    }
+    const { requests, ctx } = makeCtx()
 
     await Instance.provide({
       directory: "/tmp/project",
@@ -133,13 +113,7 @@ describe("tool.assertExternalDirectory", () => {
 
   if (process.platform === "win32") {
     test("normalizes Windows path variants to one glob", async () => {
-      const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-      const ctx: Tool.Context = {
-        ...baseCtx,
-        ask: async (req) => {
-          requests.push(req)
-        },
-      }
+      const { requests, ctx } = makeCtx()
 
       await using outerTmp = await tmpdir({
         init: async (dir) => {
@@ -169,13 +143,7 @@ describe("tool.assertExternalDirectory", () => {
     })
 
     test("uses drive root glob for root files", async () => {
-      const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
-      const ctx: Tool.Context = {
-        ...baseCtx,
-        ask: async (req) => {
-          requests.push(req)
-        },
-      }
+      const { requests, ctx } = makeCtx()
 
       await using tmp = await tmpdir({ git: true })
       const root = path.parse(tmp.path).root

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

@@ -21,7 +21,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 const projectRoot = path.join(__dirname, "../..")
@@ -32,14 +32,14 @@ describe("tool.grep", () => {
       directory: projectRoot,
       fn: async () => {
         const grep = await initGrep()
-        const result = await grep.execute(
+        const result = await Effect.runPromise(grep.execute(
           {
             pattern: "export",
             path: path.join(projectRoot, "src/tool"),
             include: "*.ts",
           },
           ctx,
-        )
+        ))
         expect(result.metadata.matches).toBeGreaterThan(0)
         expect(result.output).toContain("Found")
       },
@@ -56,13 +56,13 @@ describe("tool.grep", () => {
       directory: tmp.path,
       fn: async () => {
         const grep = await initGrep()
-        const result = await grep.execute(
+        const result = await Effect.runPromise(grep.execute(
           {
             pattern: "xyznonexistentpatternxyz123",
             path: tmp.path,
           },
           ctx,
-        )
+        ))
         expect(result.metadata.matches).toBe(0)
         expect(result.output).toBe("No files found")
       },
@@ -81,13 +81,13 @@ describe("tool.grep", () => {
       directory: tmp.path,
       fn: async () => {
         const grep = await initGrep()
-        const result = await grep.execute(
+        const result = await Effect.runPromise(grep.execute(
           {
             pattern: "line",
             path: tmp.path,
           },
           ctx,
-        )
+        ))
         expect(result.metadata.matches).toBeGreaterThan(0)
       },
     })

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

@@ -16,7 +16,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
@@ -49,7 +49,7 @@ describe("tool.question", () => {
           },
         ]
 
-        const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
+        const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
         const item = yield* pending(question)
         yield* question.reply({ requestID: item.id, answers: [["Red"]] })
 
@@ -73,7 +73,7 @@ describe("tool.question", () => {
           },
         ]
 
-        const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
+        const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
         const item = yield* pending(question)
         yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
 

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

@@ -30,7 +30,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 const it = testEffect(
@@ -54,7 +54,7 @@ const run = Effect.fn("ReadToolTest.run")(function* (
   next: Tool.Context = ctx,
 ) {
   const tool = yield* init()
-  return yield* Effect.promise(() => tool.execute(args, next))
+  return yield* tool.execute(args, next)
 })
 
 const exec = Effect.fn("ReadToolTest.exec")(function* (
@@ -95,9 +95,8 @@ const asks = () => {
     items,
     next: {
       ...ctx,
-      ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
-        items.push(req)
-      },
+      ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+        Effect.sync(() => { items.push(req) }),
     },
   }
 }
@@ -226,17 +225,18 @@ describe("tool.read env file permissions", () => {
                 let asked = false
                 const next = {
                   ...ctx,
-                  ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
-                    for (const pattern of req.patterns) {
-                      const rule = Permission.evaluate(req.permission, pattern, info.permission)
-                      if (rule.action === "ask" && req.permission === "read") {
-                        asked = true
+                  ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
+                    Effect.sync(() => {
+                      for (const pattern of req.patterns) {
+                        const rule = Permission.evaluate(req.permission, pattern, info.permission)
+                        if (rule.action === "ask" && req.permission === "read") {
+                          asked = true
+                        }
+                        if (rule.action === "deny") {
+                          throw new Permission.DeniedError({ ruleset: info.permission })
+                        }
                       }
-                      if (rule.action === "deny") {
-                        throw new Permission.DeniedError({ ruleset: info.permission })
-                      }
-                    }
-                  },
+                    }),
                 }
 
                 yield* run({ filePath: path.join(dir, filename) }, next)

+ 3 - 4
packages/opencode/test/tool/skill.test.ts

@@ -156,12 +156,11 @@ Use this skill.
           const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
           const ctx: Tool.Context = {
             ...baseCtx,
-            ask: async (req) => {
-              requests.push(req)
-            },
+            ask: (req) =>
+              Effect.sync(() => { requests.push(req) }),
           }
 
-          const result = await tool.execute({ name: "tool-skill" }, ctx)
+          const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
           const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
           const file = path.resolve(dir, "scripts", "demo.txt")
 

+ 13 - 22
packages/opencode/test/tool/task.test.ts

@@ -194,8 +194,7 @@ describe("tool.task", () => {
         let seen: SessionPrompt.PromptInput | undefined
         const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
 
-        const result = yield* Effect.promise(() =>
-          def.execute(
+        const result = yield* def.execute(
             {
               description: "inspect bug",
               prompt: "look into the cache key path",
@@ -210,10 +209,9 @@ describe("tool.task", () => {
               extra: { promptOps },
               messages: [],
               metadata() {},
-              ask: async () => {},
+              ask: () => Effect.void,
             },
-          ),
-        )
+          )
 
         const kids = yield* sessions.children(chat.id)
         expect(kids).toHaveLength(1)
@@ -235,8 +233,7 @@ describe("tool.task", () => {
         const promptOps = stubOps()
 
         const exec = (extra?: Record<string, any>) =>
-          Effect.promise(() =>
-            def.execute(
+          def.execute(
               {
                 description: "inspect bug",
                 prompt: "look into the cache key path",
@@ -250,12 +247,10 @@ describe("tool.task", () => {
                 extra: { promptOps, ...extra },
                 messages: [],
                 metadata() {},
-                ask: async (input) => {
-                  calls.push(input)
-                },
+                ask: (input) =>
+                  Effect.sync(() => { calls.push(input) }),
               },
-            ),
-          )
+            )
 
         yield* exec()
         yield* exec({ bypassAgentCheck: true })
@@ -284,8 +279,7 @@ describe("tool.task", () => {
         let seen: SessionPrompt.PromptInput | undefined
         const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
 
-        const result = yield* Effect.promise(() =>
-          def.execute(
+        const result = yield* def.execute(
             {
               description: "inspect bug",
               prompt: "look into the cache key path",
@@ -300,10 +294,9 @@ describe("tool.task", () => {
               extra: { promptOps },
               messages: [],
               metadata() {},
-              ask: async () => {},
+              ask: () => Effect.void,
             },
-          ),
-        )
+          )
 
         const kids = yield* sessions.children(chat.id)
         expect(kids).toHaveLength(1)
@@ -326,8 +319,7 @@ describe("tool.task", () => {
           let seen: SessionPrompt.PromptInput | undefined
           const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
 
-          const result = yield* Effect.promise(() =>
-            def.execute(
+          const result = yield* def.execute(
               {
                 description: "inspect bug",
                 prompt: "look into the cache key path",
@@ -341,10 +333,9 @@ describe("tool.task", () => {
                 extra: { promptOps },
                 messages: [],
                 metadata() {},
-                ask: async () => {},
+                ask: () => Effect.void,
               },
-            ),
-          )
+            )
 
           const child = yield* sessions.get(result.metadata.sessionId)
           expect(child.parentID).toBe(chat.id)

+ 15 - 12
packages/opencode/test/tool/tool-define.test.ts

@@ -1,4 +1,5 @@
 import { describe, test, expect } from "bun:test"
+import { Effect } from "effect"
 import z from "zod"
 import { Tool } from "../../src/tool/tool"
 
@@ -8,9 +9,9 @@ function makeTool(id: string, executeFn?: () => void) {
   return {
     description: "test tool",
     parameters: params,
-    async execute() {
+    execute() {
       executeFn?.()
-      return { title: "test", output: "ok", metadata: {} }
+      return Effect.succeed({ title: "test", output: "ok", metadata: {} })
     },
   }
 }
@@ -20,29 +21,31 @@ describe("Tool.define", () => {
     const original = makeTool("test")
     const originalExecute = original.execute
 
-    const tool = Tool.define("test-tool", original)
+    const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
 
-    await tool.init()
-    await tool.init()
-    await tool.init()
+    await info.init()
+    await info.init()
+    await info.init()
 
     expect(original.execute).toBe(originalExecute)
   })
 
   test("function-defined tool returns fresh objects and is unaffected", async () => {
-    const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
+    const info = await Effect.runPromise(
+      Tool.define("test-fn-tool", Effect.succeed(() => Promise.resolve(makeTool("test")))),
+    )
 
-    const first = await tool.init()
-    const second = await tool.init()
+    const first = await info.init()
+    const second = await info.init()
 
     expect(first).not.toBe(second)
   })
 
   test("object-defined tool returns distinct objects per init() call", async () => {
-    const tool = Tool.define("test-copy", makeTool("test"))
+    const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
 
-    const first = await tool.init()
-    const second = await tool.init()
+    const first = await info.init()
+    const second = await info.init()
 
     expect(first).not.toBe(second)
   })

+ 5 - 5
packages/opencode/test/tool/webfetch.test.ts

@@ -16,7 +16,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
@@ -42,10 +42,10 @@ describe("tool.webfetch", () => {
           directory: projectRoot,
           fn: async () => {
             const webfetch = await initTool()
-            const result = await webfetch.execute(
+            const result = await Effect.runPromise(webfetch.execute(
               { url: new URL("/image.png", url).toString(), format: "markdown" },
               ctx,
-            )
+            ))
             expect(result.output).toBe("Image fetched successfully")
             expect(result.attachments).toBeDefined()
             expect(result.attachments?.length).toBe(1)
@@ -74,7 +74,7 @@ describe("tool.webfetch", () => {
           directory: projectRoot,
           fn: async () => {
             const webfetch = await initTool()
-            const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
+            const result = await Effect.runPromise(webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx))
             expect(result.output).toContain("<svg")
             expect(result.attachments).toBeUndefined()
           },
@@ -95,7 +95,7 @@ describe("tool.webfetch", () => {
           directory: projectRoot,
           fn: async () => {
             const webfetch = await initTool()
-            const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
+            const result = await Effect.runPromise(webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx))
             expect(result.output).toBe("hello from webfetch")
             expect(result.attachments).toBeUndefined()
           },

+ 5 - 3
packages/opencode/test/tool/write.test.ts

@@ -7,6 +7,8 @@ import { Instance } from "../../src/project/instance"
 import { LSP } from "../../src/lsp"
 import { AppFileSystem } from "../../src/filesystem"
 import { FileTime } from "../../src/file/time"
+import { Bus } from "../../src/bus"
+import { Format } from "../../src/format"
 import { Tool } from "../../src/tool/tool"
 import { SessionID, MessageID } from "../../src/session/schema"
 import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -21,7 +23,7 @@ const ctx = {
   abort: AbortSignal.any([]),
   messages: [],
   metadata: () => {},
-  ask: async () => {},
+  ask: () => Effect.void,
 }
 
 afterEach(async () => {
@@ -29,7 +31,7 @@ afterEach(async () => {
 })
 
 const it = testEffect(
-  Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
+  Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, Bus.layer, Format.defaultLayer, CrossSpawnSpawner.defaultLayer),
 )
 
 const init = Effect.fn("WriteToolTest.init")(function* () {
@@ -42,7 +44,7 @@ const run = Effect.fn("WriteToolTest.run")(function* (
   next: Tool.Context = ctx,
 ) {
   const tool = yield* init()
-  return yield* Effect.promise(() => tool.execute(args, next))
+  return yield* tool.execute(args, next)
 })
 
 const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {