Przeglądaj źródła

refactor(tool): convert bash to defineEffect with ChildProcessSpawner (#21895)

Kit Langton 1 tydzień temu
rodzic
commit
f7514d9eca

+ 1 - 1
packages/opencode/src/effect/run-service.ts

@@ -8,7 +8,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context"
 
 export const memoMap = Layer.makeMemoMapUnsafe()
 
-function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
+export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
   try {
     const ctx = Instance.current
     const workspaceID = WorkspaceContext.workspaceID

+ 232 - 222
packages/opencode/src/tool/bash.ts

@@ -8,8 +8,7 @@ import { Instance } from "../project/instance"
 import { lazy } from "@/util/lazy"
 import { Language, type Node } from "web-tree-sitter"
 
-import { Filesystem } from "@/util/filesystem"
-import { Process } from "@/util/process"
+import { AppFileSystem } from "@/filesystem"
 import { fileURLToPath } from "url"
 import { Flag } from "@/flag/flag"
 import { Shell } from "@/shell/shell"
@@ -17,9 +16,9 @@ import { Shell } from "@/shell/shell"
 import { BashArity } from "@/permission/arity"
 import { Truncate } from "./truncate"
 import { Plugin } from "@/plugin"
-import { Cause, Effect, Exit, Stream } from "effect"
-import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+import { Effect, Stream } from "effect"
+import { ChildProcess } from "effect/unstable/process"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 
 const MAX_METADATA_LENGTH = 30_000
 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -183,33 +182,6 @@ function prefix(text: string) {
   return text.slice(0, match.index)
 }
 
-async function cygpath(shell: string, text: string) {
-  const out = await Process.text([shell, "-lc", 'cygpath -w -- "$1"', "_", text], { nothrow: true })
-  if (out.code !== 0) return
-  const file = out.text.trim()
-  if (!file) return
-  return Filesystem.normalizePath(file)
-}
-
-async function resolvePath(text: string, root: string, shell: string) {
-  if (process.platform === "win32") {
-    if (Shell.posix(shell) && text.startsWith("/") && Filesystem.windowsPath(text) === text) {
-      const file = await cygpath(shell, text)
-      if (file) return file
-    }
-    return Filesystem.normalizePath(path.resolve(root, Filesystem.windowsPath(text)))
-  }
-  return path.resolve(root, text)
-}
-
-async function argPath(arg: string, cwd: string, ps: boolean, shell: string) {
-  const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
-  const file = text && prefix(text)
-  if (!file || dynamic(file, ps)) return
-  const next = ps ? provider(file) : file
-  if (!next) return
-  return resolvePath(next, cwd, shell)
-}
 
 function pathArgs(list: Part[], ps: boolean) {
   if (!ps) {
@@ -238,78 +210,45 @@ function pathArgs(list: Part[], ps: boolean) {
   return out
 }
 
-async function collect(root: Node, cwd: string, ps: boolean, shell: string): Promise<Scan> {
-  const scan: Scan = {
-    dirs: new Set<string>(),
-    patterns: new Set<string>(),
-    always: new Set<string>(),
-  }
-
-  for (const node of commands(root)) {
-    const command = parts(node)
-    const tokens = command.map((item) => item.text)
-    const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
-
-    if (cmd && FILES.has(cmd)) {
-      for (const arg of pathArgs(command, ps)) {
-        const resolved = await argPath(arg, cwd, ps, shell)
-        log.info("resolved path", { arg, resolved })
-        if (!resolved || Instance.containsPath(resolved)) continue
-        const dir = (await Filesystem.isDir(resolved)) ? resolved : path.dirname(resolved)
-        scan.dirs.add(dir)
-      }
-    }
-
-    if (tokens.length && (!cmd || !CWD.has(cmd))) {
-      scan.patterns.add(source(node))
-      scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
-    }
-  }
-
-  return scan
-}
 
 function preview(text: string) {
   if (text.length <= MAX_METADATA_LENGTH) return text
   return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
 }
 
-async function parse(command: string, ps: boolean) {
-  const tree = await parser().then((p) => (ps ? p.ps : p.bash).parse(command))
+const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
+  const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command)))
   if (!tree) throw new Error("Failed to parse command")
   return tree.rootNode
-}
+})
 
-async function ask(ctx: Tool.Context, scan: Scan) {
+const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) {
   if (scan.dirs.size > 0) {
     const globs = Array.from(scan.dirs).map((dir) => {
-      if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
+      if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
       return path.join(dir, "*")
     })
-    await ctx.ask({
-      permission: "external_directory",
-      patterns: globs,
-      always: globs,
-      metadata: {},
-    })
+    yield* Effect.promise(() =>
+      ctx.ask({
+        permission: "external_directory",
+        patterns: globs,
+        always: globs,
+        metadata: {},
+      }),
+    )
   }
 
   if (scan.patterns.size === 0) return
-  await ctx.ask({
-    permission: "bash",
-    patterns: Array.from(scan.patterns),
-    always: Array.from(scan.always),
-    metadata: {},
-  })
-}
+  yield* Effect.promise(() =>
+    ctx.ask({
+      permission: "bash",
+      patterns: Array.from(scan.patterns),
+      always: Array.from(scan.always),
+      metadata: {},
+    }),
+  )
+})
 
-async function shellEnv(ctx: Tool.Context, cwd: string) {
-  const extra = await Plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
-  return {
-    ...process.env,
-    ...extra.env,
-  }
-}
 
 function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
   if (process.platform === "win32" && PS.has(name)) {
@@ -330,99 +269,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
   })
 }
 
-async function run(
-  input: {
-    shell: string
-    name: string
-    command: string
-    cwd: string
-    env: NodeJS.ProcessEnv
-    timeout: number
-    description: string
-  },
-  ctx: Tool.Context,
-) {
-  let output = ""
-  let expired = false
-  let aborted = false
-
-  ctx.metadata({
-    metadata: {
-      output: "",
-      description: input.description,
-    },
-  })
-
-  const exit = await CrossSpawnSpawner.runPromiseExit((spawner) =>
-    Effect.gen(function* () {
-      const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
-
-      yield* Effect.forkScoped(
-        Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
-          Effect.sync(() => {
-            output += chunk
-            ctx.metadata({
-              metadata: {
-                output: preview(output),
-                description: input.description,
-              },
-            })
-          }),
-        ),
-      )
-
-      const abort = Effect.callback<void>((resume) => {
-        if (ctx.abort.aborted) return resume(Effect.void)
-        const handler = () => resume(Effect.void)
-        ctx.abort.addEventListener("abort", handler, { once: true })
-        return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
-      })
-
-      const timeout = Effect.sleep(`${input.timeout + 100} millis`)
-
-      const exit = yield* Effect.raceAll([
-        handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
-        abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
-        timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
-      ])
-
-      if (exit.kind === "abort") {
-        aborted = true
-        yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
-      }
-      if (exit.kind === "timeout") {
-        expired = true
-        yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
-      }
-
-      return exit.kind === "exit" ? exit.code : null
-    }).pipe(Effect.scoped, Effect.orDie),
-  )
-
-  let code: number | null = null
-  if (Exit.isSuccess(exit)) {
-    code = exit.value
-  } else if (!Cause.hasInterruptsOnly(exit.cause)) {
-    throw Cause.squash(exit.cause)
-  }
-
-  const meta: string[] = []
-  if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
-  if (aborted) meta.push("User aborted the command")
-  if (meta.length > 0) {
-    output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
-  }
-
-  return {
-    title: input.description,
-    metadata: {
-      output: preview(output),
-      exit: code,
-      description: input.description,
-    },
-    output,
-  }
-}
 
 const parser = lazy(async () => {
   const { Parser } = await import("web-tree-sitter")
@@ -452,47 +298,211 @@ const parser = lazy(async () => {
 })
 
 // TODO: we may wanna rename this tool so it works better on other shells
-export const BashTool = Tool.define("bash", async () => {
-  const shell = Shell.acceptable()
-  const name = Shell.name(shell)
-  const chain =
-    name === "powershell"
-      ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
-      : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
-  log.info("bash tool using shell", { shell })
-
-  return {
-    description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
-      .replaceAll("${os}", process.platform)
-      .replaceAll("${shell}", name)
-      .replaceAll("${chaining}", chain)
-      .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
-      .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
-    parameters: Parameters,
-    async execute(params, ctx) {
-      const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
-      if (params.timeout !== undefined && params.timeout < 0) {
-        throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
+export const BashTool = Tool.defineEffect(
+  "bash",
+  Effect.gen(function* () {
+    const spawner = yield* ChildProcessSpawner
+    const fs = yield* AppFileSystem.Service
+    const plugin = yield* Plugin.Service
+
+    const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
+      const lines = yield* spawner.lines(
+        ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text]),
+      ).pipe(Effect.catch(() => Effect.succeed([] as string[])))
+      const file = lines[0]?.trim()
+      if (!file) return
+      return AppFileSystem.normalizePath(file)
+    })
+
+    const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) {
+      if (process.platform === "win32") {
+        if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) {
+          const file = yield* cygpath(shell, text)
+          if (file) return file
+        }
+        return AppFileSystem.normalizePath(path.resolve(root, AppFileSystem.windowsPath(text)))
+      }
+      return path.resolve(root, text)
+    })
+
+    const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) {
+      const text = ps ? expand(arg, cwd, shell) : home(unquote(arg))
+      const file = text && prefix(text)
+      if (!file || dynamic(file, ps)) return
+      const next = ps ? provider(file) : file
+      if (!next) return
+      return yield* resolvePath(next, cwd, shell)
+    })
+
+    const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) {
+      const scan: Scan = {
+        dirs: new Set<string>(),
+        patterns: new Set<string>(),
+        always: new Set<string>(),
+      }
+
+      for (const node of commands(root)) {
+        const command = parts(node)
+        const tokens = command.map((item) => item.text)
+        const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0]
+
+        if (cmd && FILES.has(cmd)) {
+          for (const arg of pathArgs(command, ps)) {
+            const resolved = yield* argPath(arg, cwd, ps, shell)
+            log.info("resolved path", { arg, resolved })
+            if (!resolved || Instance.containsPath(resolved)) continue
+            const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
+            scan.dirs.add(dir)
+          }
+        }
+
+        if (tokens.length && (!cmd || !CWD.has(cmd))) {
+          scan.patterns.add(source(node))
+          scan.always.add(BashArity.prefix(tokens).join(" ") + " *")
+        }
+      }
+
+      return scan
+    })
+
+    const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) {
+      const extra = yield* plugin.trigger("shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, { env: {} })
+      return {
+        ...process.env,
+        ...extra.env,
       }
-      const timeout = params.timeout ?? DEFAULT_TIMEOUT
-      const ps = PS.has(name)
-      const root = await parse(params.command, ps)
-      const scan = await collect(root, cwd, ps, shell)
-      if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
-      await ask(ctx, scan)
-
-      return run(
-        {
-          shell,
-          name,
-          command: params.command,
-          cwd,
-          env: await shellEnv(ctx, cwd),
-          timeout,
-          description: params.description,
+    })
+
+    const run = Effect.fn("BashTool.run")(function* (
+      input: {
+        shell: string
+        name: string
+        command: string
+        cwd: string
+        env: NodeJS.ProcessEnv
+        timeout: number
+        description: string
+      },
+      ctx: Tool.Context,
+    ) {
+      let output = ""
+      let expired = false
+      let aborted = false
+
+      ctx.metadata({
+        metadata: {
+          output: "",
+          description: input.description,
         },
-        ctx,
-      )
-    },
-  }
-})
+      })
+
+      const code: number | null = yield* Effect.scoped(
+        Effect.gen(function* () {
+          const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
+
+          yield* Effect.forkScoped(
+            Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
+              Effect.sync(() => {
+                output += chunk
+                ctx.metadata({
+                  metadata: {
+                    output: preview(output),
+                    description: input.description,
+                  },
+                })
+              }),
+            ),
+          )
+
+          const abort = Effect.callback<void>((resume) => {
+            if (ctx.abort.aborted) return resume(Effect.void)
+            const handler = () => resume(Effect.void)
+            ctx.abort.addEventListener("abort", handler, { once: true })
+            return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
+          })
+
+          const timeout = Effect.sleep(`${input.timeout + 100} millis`)
+
+          const exit = yield* Effect.raceAll([
+            handle.exitCode.pipe(Effect.map((code) => ({ kind: "exit" as const, code }))),
+            abort.pipe(Effect.map(() => ({ kind: "abort" as const, code: null }))),
+            timeout.pipe(Effect.map(() => ({ kind: "timeout" as const, code: null }))),
+          ])
+
+          if (exit.kind === "abort") {
+            aborted = true
+            yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
+          }
+          if (exit.kind === "timeout") {
+            expired = true
+            yield* handle.kill({ forceKillAfter: "3 seconds" }).pipe(Effect.orDie)
+          }
+
+          return exit.kind === "exit" ? exit.code : null
+        }),
+      ).pipe(Effect.orDie)
+
+      const meta: string[] = []
+      if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
+      if (aborted) meta.push("User aborted the command")
+      if (meta.length > 0) {
+        output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
+      }
+
+      return {
+        title: input.description,
+        metadata: {
+          output: preview(output),
+          exit: code,
+          description: input.description,
+        },
+        output,
+      }
+    })
+
+    return async () => {
+      const shell = Shell.acceptable()
+      const name = Shell.name(shell)
+      const chain =
+        name === "powershell"
+          ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
+          : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
+      log.info("bash tool using shell", { shell })
+
+      return {
+        description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
+          .replaceAll("${os}", process.platform)
+          .replaceAll("${shell}", name)
+          .replaceAll("${chaining}", chain)
+          .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
+          .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
+        parameters: Parameters,
+      execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
+        Effect.gen(function* () {
+          const cwd = params.workdir
+            ? yield* resolvePath(params.workdir, Instance.directory, shell)
+            : Instance.directory
+          if (params.timeout !== undefined && params.timeout < 0) {
+            throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
+          }
+          const timeout = params.timeout ?? DEFAULT_TIMEOUT
+          const ps = PS.has(name)
+          const root = yield* parse(params.command, ps)
+          const scan = yield* collect(root, cwd, ps, shell)
+          if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
+          yield* ask(ctx, scan)
+
+          return yield* run({
+            shell,
+            name,
+            command: params.command,
+            cwd,
+            env: yield* shellEnv(ctx, cwd),
+            timeout,
+            description: params.description,
+          }, ctx)
+        }).pipe(Effect.orDie, Effect.runPromise),
+      }
+    }
+  }),
+)

+ 6 - 1
packages/opencode/src/tool/registry.ts

@@ -31,6 +31,8 @@ import path from "path"
 import { pathToFileURL } from "url"
 import { Effect, Layer, ServiceMap } from "effect"
 import { FetchHttpClient, HttpClient } from "effect/unstable/http"
+import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { Env } from "../env"
@@ -86,6 +88,7 @@ export namespace ToolRegistry {
     | Instruction.Service
     | AppFileSystem.Service
     | HttpClient.HttpClient
+    | ChildProcessSpawner
   > = Layer.effect(
     Service,
     Effect.gen(function* () {
@@ -102,6 +105,7 @@ export namespace ToolRegistry {
       const plan = yield* PlanExitTool
       const webfetch = yield* WebFetchTool
       const websearch = yield* WebSearchTool
+      const bash = yield* BashTool
       const codesearch = yield* CodeSearchTool
 
       const state = yield* InstanceState.make<State>(
@@ -161,7 +165,7 @@ export namespace ToolRegistry {
 
           const tool = yield* Effect.all({
             invalid: Tool.init(InvalidTool),
-            bash: Tool.init(BashTool),
+            bash: Tool.init(bash),
             read: Tool.init(read),
             glob: Tool.init(GlobTool),
             grep: Tool.init(GrepTool),
@@ -315,6 +319,7 @@ export namespace ToolRegistry {
       Layer.provide(Instruction.defaultLayer),
       Layer.provide(AppFileSystem.defaultLayer),
       Layer.provide(FetchHttpClient.layer),
+      Layer.provide(CrossSpawnSpawner.defaultLayer),
     ),
   )
 

+ 1 - 0
packages/opencode/test/session/prompt-effect.test.ts

@@ -171,6 +171,7 @@ function makeHttp() {
   const registry = ToolRegistry.layer.pipe(
     Layer.provide(Skill.defaultLayer),
     Layer.provide(FetchHttpClient.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provideMerge(todo),
     Layer.provideMerge(question),
     Layer.provideMerge(deps),

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

@@ -135,6 +135,7 @@ function makeHttp() {
   const registry = ToolRegistry.layer.pipe(
     Layer.provide(Skill.defaultLayer),
     Layer.provide(FetchHttpClient.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provideMerge(todo),
     Layer.provideMerge(question),
     Layer.provideMerge(deps),

+ 52 - 38
packages/opencode/test/tool/bash.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, test } from "bun:test"
+import { Effect, Layer, ManagedRuntime } from "effect"
 import os from "os"
 import path from "path"
 import { Shell } from "../../src/shell/shell"
@@ -9,6 +10,19 @@ import { tmpdir } from "../fixture/fixture"
 import type { Permission } from "../../src/permission"
 import { Truncate } from "../../src/tool/truncate"
 import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { AppFileSystem } from "../../src/filesystem"
+import { Plugin } from "../../src/plugin"
+
+const runtime = ManagedRuntime.make(
+  Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
+)
+
+function initBash() {
+  return runtime.runPromise(
+    BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))),
+  )
+}
 
 const ctx = {
   sessionID: SessionID.make("ses_test"),
@@ -118,7 +132,7 @@ describe("tool.bash", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const result = await bash.execute(
           {
             command: "echo test",
@@ -139,7 +153,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await bash.execute(
           {
@@ -160,7 +174,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await bash.execute(
           {
@@ -184,7 +198,7 @@ describe("tool.bash permissions", () => {
         await Instance.provide({
           directory: projectRoot,
           fn: async () => {
-            const bash = await BashTool.init()
+            const bash = await initBash()
             const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
             await bash.execute(
               {
@@ -208,7 +222,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
@@ -242,7 +256,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              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(
@@ -273,7 +287,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -301,7 +315,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
               await bash.execute(
@@ -331,7 +345,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: tmp.path,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -359,7 +373,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -388,7 +402,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: tmp.path,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -416,7 +430,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -448,7 +462,7 @@ describe("tool.bash permissions", () => {
             await Instance.provide({
               directory: projectRoot,
               fn: async () => {
-                const bash = await BashTool.init()
+                const bash = await initBash()
                 const err = new Error("stop after permission")
                 const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
                 const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
@@ -481,7 +495,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await bash.execute(
                 {
@@ -508,7 +522,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -538,7 +552,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await expect(
@@ -568,7 +582,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await bash.execute(
                 {
@@ -597,7 +611,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               await bash.execute(
                 {
@@ -622,7 +636,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await expect(
@@ -645,7 +659,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await expect(
@@ -673,7 +687,7 @@ describe("tool.bash permissions", () => {
       await Instance.provide({
         directory: tmp.path,
         fn: async () => {
-          const bash = await BashTool.init()
+          const bash = await initBash()
           const want = Filesystem.normalizePathPattern(path.join(outerTmp.path, "*"))
 
           for (const dir of forms(outerTmp.path)) {
@@ -707,7 +721,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               const want = glob(path.join(os.tmpdir(), "*"))
@@ -737,7 +751,7 @@ describe("tool.bash permissions", () => {
           await Instance.provide({
             directory: projectRoot,
             fn: async () => {
-              const bash = await BashTool.init()
+              const bash = await initBash()
               const err = new Error("stop after permission")
               const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
               const want = glob(path.join(os.tmpdir(), "*"))
@@ -772,7 +786,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         const filepath = path.join(outerTmp.path, "outside.txt")
@@ -803,7 +817,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await bash.execute(
           {
@@ -823,7 +837,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await bash.execute(
           {
@@ -844,7 +858,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await bash.execute(
           {
@@ -864,7 +878,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const err = new Error("stop after permission")
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await expect(
@@ -885,7 +899,7 @@ describe("tool.bash permissions", () => {
     await Instance.provide({
       directory: tmp.path,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
         await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
         const bashReq = requests.find((r) => r.permission === "bash")
@@ -901,7 +915,7 @@ describe("tool.bash abort", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const controller = new AbortController()
         const collected: string[] = []
         const result = bash.execute(
@@ -933,7 +947,7 @@ describe("tool.bash abort", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const result = await bash.execute(
           {
             command: `echo started && sleep 60`,
@@ -952,7 +966,7 @@ describe("tool.bash abort", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const result = await bash.execute(
           {
             command: `echo stdout_msg && echo stderr_msg >&2`,
@@ -971,7 +985,7 @@ describe("tool.bash abort", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const result = await bash.execute(
           {
             command: `exit 42`,
@@ -988,7 +1002,7 @@ describe("tool.bash abort", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const updates: string[] = []
         const result = await bash.execute(
           {
@@ -1016,7 +1030,7 @@ describe("tool.bash truncation", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const lineCount = Truncate.MAX_LINES + 500
         const result = await bash.execute(
           {
@@ -1036,7 +1050,7 @@ describe("tool.bash truncation", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const byteCount = Truncate.MAX_BYTES + 10000
         const result = await bash.execute(
           {
@@ -1056,7 +1070,7 @@ describe("tool.bash truncation", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const result = await bash.execute(
           {
             command: "echo hello",
@@ -1074,7 +1088,7 @@ describe("tool.bash truncation", () => {
     await Instance.provide({
       directory: projectRoot,
       fn: async () => {
-        const bash = await BashTool.init()
+        const bash = await initBash()
         const lineCount = Truncate.MAX_LINES + 100
         const result = await bash.execute(
           {