Explorar el Código

refactor(effect): yield services instead of promise facades (#19325)

Kit Langton hace 3 semanas
padre
commit
9c6f1edfd7

+ 17 - 14
packages/opencode/specs/effect-migration.md

@@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
 
 Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
 
-- Global services (no per-directory state): Account, Auth, Installation, Truncate
-- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
+- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
+- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
 
 Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
 
@@ -181,36 +181,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
 Fully migrated (single namespace, InstanceState where needed, flattened facade):
 
 - [x] `Account` — `account/index.ts`
+- [x] `Agent` — `agent/agent.ts`
+- [x] `AppFileSystem` — `filesystem/index.ts`
 - [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
+- [x] `Bus` — `bus/index.ts`
+- [x] `Command` — `command/index.ts`
+- [x] `Config` — `config/config.ts`
+- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
 - [x] `File` — `file/index.ts`
 - [x] `FileTime` — `file/time.ts`
 - [x] `FileWatcher` — `file/watcher.ts`
 - [x] `Format` — `format/index.ts`
 - [x] `Installation` — `installation/index.ts`
+- [x] `LSP` — `lsp/index.ts`
+- [x] `MCP` — `mcp/index.ts`
+- [x] `McpAuth` — `mcp/auth.ts`
 - [x] `Permission` — `permission/index.ts`
+- [x] `Plugin` — `plugin/index.ts`
+- [x] `Project` — `project/project.ts`
 - [x] `ProviderAuth` — `provider/auth.ts`
+- [x] `Pty` — `pty/index.ts`
 - [x] `Question` — `question/index.ts`
+- [x] `SessionStatus` — `session/status.ts`
 - [x] `Skill` — `skill/index.ts`
 - [x] `Snapshot` — `snapshot/index.ts`
+- [x] `ToolRegistry` — `tool/registry.ts`
 - [x] `Truncate` — `tool/truncate.ts`
 - [x] `Vcs` — `project/vcs.ts`
-- [x] `Discovery` — `skill/discovery.ts`
-- [x] `SessionStatus`
+- [x] `Worktree` — `worktree/index.ts`
 
 Still open and likely worth migrating:
 
-- [x] `Plugin`
-- [x] `ToolRegistry`
-- [ ] `Pty`
-- [x] `Worktree`
-- [x] `Bus`
-- [x] `Command`
-- [x] `Config`
 - [ ] `Session`
 - [ ] `SessionProcessor`
 - [ ] `SessionPrompt`
 - [ ] `SessionCompaction`
 - [ ] `Provider`
-- [x] `Project`
-- [x] `LSP`
-- [x] `MCP`

+ 12 - 7
packages/opencode/src/agent/agent.ts

@@ -72,13 +72,14 @@ export namespace Agent {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
-      const config = () => Effect.promise(() => Config.get())
+      const config = yield* Config.Service
       const auth = yield* Auth.Service
+      const skill = yield* Skill.Service
 
       const state = yield* InstanceState.make<State>(
         Effect.fn("Agent.state")(function* (ctx) {
-          const cfg = yield* config()
-          const skillDirs = yield* Effect.promise(() => Skill.dirs())
+          const cfg = yield* config.get()
+          const skillDirs = yield* skill.dirs()
           const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
 
           const defaults = Permission.fromConfig({
@@ -281,7 +282,7 @@ export namespace Agent {
           })
 
           const list = Effect.fnUntraced(function* () {
-            const cfg = yield* config()
+            const cfg = yield* config.get()
             return pipe(
               agents,
               values(),
@@ -293,7 +294,7 @@ export namespace Agent {
           })
 
           const defaultAgent = Effect.fnUntraced(function* () {
-            const c = yield* config()
+            const c = yield* config.get()
             if (c.default_agent) {
               const agent = agents[c.default_agent]
               if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
@@ -328,7 +329,7 @@ export namespace Agent {
           description: string
           model?: { providerID: ProviderID; modelID: ModelID }
         }) {
-          const cfg = yield* config()
+          const cfg = yield* config.get()
           const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
           const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
           const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
@@ -391,7 +392,11 @@ export namespace Agent {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Auth.layer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(Skill.defaultLayer),
+  )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 19 - 9
packages/opencode/src/command/index.ts

@@ -75,8 +75,12 @@ export namespace Command {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+      const mcp = yield* MCP.Service
+      const skill = yield* Skill.Service
+
       const init = Effect.fn("Command.state")(function* (ctx) {
-        const cfg = yield* Effect.promise(() => Config.get())
+        const cfg = yield* config.get()
         const commands: Record<string, Info> = {}
 
         commands[Default.INIT] = {
@@ -114,7 +118,7 @@ export namespace Command {
           }
         }
 
-        for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
+        for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
           commands[name] = {
             name,
             source: "mcp",
@@ -139,14 +143,14 @@ export namespace Command {
           }
         }
 
-        for (const skill of yield* Effect.promise(() => Skill.all())) {
-          if (commands[skill.name]) continue
-          commands[skill.name] = {
-            name: skill.name,
-            description: skill.description,
+        for (const item of yield* skill.all()) {
+          if (commands[item.name]) continue
+          commands[item.name] = {
+            name: item.name,
+            description: item.description,
             source: "skill",
             get template() {
-              return skill.content
+              return item.content
             },
             hints: [],
           }
@@ -173,7 +177,13 @@ export namespace Command {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(MCP.defaultLayer),
+    Layer.provide(Skill.defaultLayer),
+  )
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function get(name: string) {
     return runPromise((svc) => svc.get(name))

+ 17 - 8
packages/opencode/src/config/config.ts

@@ -40,7 +40,7 @@ import { Lock } from "@/util/lock"
 import { AppFileSystem } from "@/filesystem"
 import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
-import { Duration, Effect, Layer, ServiceMap } from "effect"
+import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
 
 export namespace Config {
   const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -1136,10 +1136,12 @@ export namespace Config {
     }),
   )
 
-  export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
+  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> = Layer.effect(
     Service,
     Effect.gen(function* () {
       const fs = yield* AppFileSystem.Service
+      const authSvc = yield* Auth.Service
+      const accountSvc = yield* Account.Service
 
       const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
         return yield* fs.readFileString(filepath).pipe(
@@ -1256,7 +1258,7 @@ export namespace Config {
       })
 
       const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
-        const auth = yield* Effect.promise(() => Auth.all())
+        const auth = yield* authSvc.all().pipe(Effect.orDie)
 
         let result: Info = {}
         for (const [key, value] of Object.entries(auth)) {
@@ -1344,17 +1346,20 @@ export namespace Config {
           log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
         }
 
-        const active = yield* Effect.promise(() => Account.active())
+        const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
         if (active?.active_org_id) {
           yield* Effect.gen(function* () {
-            const [config, token] = yield* Effect.promise(() =>
-              Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]),
+            const [configOpt, tokenOpt] = yield* Effect.all(
+              [accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
+              { concurrency: 2 },
             )
+            const token = Option.getOrUndefined(tokenOpt)
             if (token) {
               process.env["OPENCODE_CONSOLE_TOKEN"] = token
               Env.set("OPENCODE_CONSOLE_TOKEN", token)
             }
 
+            const config = Option.getOrUndefined(configOpt)
             if (config) {
               result = mergeConfigConcatArrays(
                 result,
@@ -1365,7 +1370,7 @@ export namespace Config {
               )
             }
           }).pipe(
-            Effect.catchDefect((err) => {
+            Effect.catch((err) => {
               log.debug("failed to fetch remote account config", {
                 error: err instanceof Error ? err.message : String(err),
               })
@@ -1502,7 +1507,11 @@ export namespace Config {
     }),
   )
 
-  export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+  export const defaultLayer = layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Auth.layer),
+    Layer.provide(Account.defaultLayer),
+  )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 4 - 1
packages/opencode/src/effect/cross-spawn-spawner.ts

@@ -1,5 +1,6 @@
 import type * as Arr from "effect/Array"
-import { NodeSink, NodeStream } from "@effect/platform-node"
+import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
+import * as NodePath from "@effect/platform-node/NodePath"
 import * as Deferred from "effect/Deferred"
 import * as Effect from "effect/Effect"
 import * as Exit from "effect/Exit"
@@ -474,3 +475,5 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
   ChildProcessSpawner,
   make,
 )
+
+export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))

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

@@ -70,6 +70,8 @@ export namespace FileWatcher {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+
       const state = yield* InstanceState.make(
         Effect.fn("FileWatcher.state")(
           function* () {
@@ -117,7 +119,7 @@ export namespace FileWatcher {
               )
             }
 
-            const cfg = yield* Effect.promise(() => Config.get())
+            const cfg = yield* config.get()
             const cfgIgnores = cfg.watcher?.ignore ?? []
 
             if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
@@ -159,7 +161,9 @@ export namespace FileWatcher {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export function init() {
     return runPromise((svc) => svc.init())

+ 6 - 2
packages/opencode/src/format/index.ts

@@ -35,12 +35,14 @@ export namespace Format {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+
       const state = yield* InstanceState.make(
         Effect.fn("Format.state")(function* (_ctx) {
           const enabled: Record<string, boolean> = {}
           const formatters: Record<string, Formatter.Info> = {}
 
-          const cfg = yield* Effect.promise(() => Config.get())
+          const cfg = yield* config.get()
 
           if (cfg.formatter !== false) {
             for (const item of Object.values(Formatter)) {
@@ -167,7 +169,9 @@ export namespace Format {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export async function init() {
     return runPromise((s) => s.init())

+ 1 - 4
packages/opencode/src/installation/index.ts

@@ -1,4 +1,3 @@
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -341,9 +340,7 @@ export namespace Installation {
 
   export const defaultLayer = layer.pipe(
     Layer.provide(FetchHttpClient.layer),
-    Layer.provide(CrossSpawnSpawner.layer),
-    Layer.provide(NodeFileSystem.layer),
-    Layer.provide(NodePath.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 6 - 2
packages/opencode/src/lsp/index.ts

@@ -161,9 +161,11 @@ export namespace LSP {
   export const layer = Layer.effect(
     Service,
     Effect.gen(function* () {
+      const config = yield* Config.Service
+
       const state = yield* InstanceState.make<State>(
         Effect.fn("LSP.state")(function* () {
-          const cfg = yield* Effect.promise(() => Config.get())
+          const cfg = yield* config.get()
 
           const servers: Record<string, LSPServer.Info> = {}
 
@@ -504,7 +506,9 @@ export namespace LSP {
     }),
   )
 
-  const { runPromise } = makeRuntime(Service, layer)
+  export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
+
+  const { runPromise } = makeRuntime(Service, defaultLayer)
 
   export const init = async () => runPromise((svc) => svc.init())
 

+ 12 - 11
packages/opencode/src/mcp/index.ts

@@ -29,8 +29,6 @@ import { InstanceState } from "@/effect/instance-state"
 import { makeRuntime } from "@/effect/run-service"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-import { NodeFileSystem } from "@effect/platform-node"
-import * as NodePath from "@effect/platform-node/NodePath"
 
 export namespace MCP {
   const log = Log.create({ service: "mcp" })
@@ -437,6 +435,7 @@ export namespace MCP {
         log.info("create() successfully created client", { key, toolCount: listed.length })
         return { mcpClient, status, defs: listed } satisfies CreateResult
       })
+      const cfgSvc = yield* Config.Service
 
       const descendants = Effect.fnUntraced(
         function* (pid: number) {
@@ -478,11 +477,11 @@ export namespace MCP {
         })
       }
 
-      const getConfig = () => Effect.promise(() => Config.get())
 
       const cache = yield* InstanceState.make<State>(
         Effect.fn("MCP.state")(function* () {
-          const cfg = yield* getConfig()
+
+          const cfg = yield* cfgSvc.get()
           const config = cfg.mcp ?? {}
           const s: State = {
             status: {},
@@ -553,7 +552,8 @@ export namespace MCP {
 
       const status = Effect.fn("MCP.status")(function* () {
         const s = yield* InstanceState.get(cache)
-        const cfg = yield* getConfig()
+
+        const cfg = yield* cfgSvc.get()
         const config = cfg.mcp ?? {}
         const result: Record<string, Status> = {}
 
@@ -613,7 +613,8 @@ export namespace MCP {
       const tools = Effect.fn("MCP.tools")(function* () {
         const result: Record<string, Tool> = {}
         const s = yield* InstanceState.get(cache)
-        const cfg = yield* getConfig()
+
+        const cfg = yield* cfgSvc.get()
         const config = cfg.mcp ?? {}
         const defaultTimeout = cfg.experimental?.mcp_timeout
 
@@ -705,7 +706,8 @@ export namespace MCP {
       })
 
       const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
-        const cfg = yield* getConfig()
+
+        const cfg = yield* cfgSvc.get()
         const mcpConfig = cfg.mcp?.[mcpName]
         if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
         return mcpConfig
@@ -876,13 +878,12 @@ export namespace MCP {
 
   // --- Per-service runtime ---
 
-  const defaultLayer = layer.pipe(
+  export const defaultLayer = layer.pipe(
     Layer.provide(McpAuth.layer),
     Layer.provide(Bus.layer),
-    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(Config.defaultLayer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer),
-    Layer.provide(NodePath.layer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 9 - 10
packages/opencode/src/project/project.ts

@@ -111,7 +111,7 @@ export namespace Project {
   > = Layer.effect(
     Service,
     Effect.gen(function* () {
-      const fsys = yield* AppFileSystem.Service
+      const fs = yield* AppFileSystem.Service
       const pathSvc = yield* Path.Path
       const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
 
@@ -155,7 +155,7 @@ export namespace Project {
       const scope = yield* Scope.Scope
 
       const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
-        return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
+        return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
           Effect.map((x) => x.trim()),
           Effect.map(ProjectID.make),
           Effect.catch(() => Effect.succeed(undefined)),
@@ -169,7 +169,7 @@ export namespace Project {
         type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
 
         const data: DiscoveryResult = yield* Effect.gen(function* () {
-          const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
+          const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
           const dotgit = dotgitMatches[0]
 
           if (!dotgit) {
@@ -222,7 +222,7 @@ export namespace Project {
 
             id = roots[0] ? ProjectID.make(roots[0]) : undefined
             if (id) {
-              yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
+              yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
             }
           }
 
@@ -270,7 +270,7 @@ export namespace Project {
         result.sandboxes = yield* Effect.forEach(
           result.sandboxes,
           (s) =>
-            fsys.exists(s).pipe(
+            fs.exists(s).pipe(
               Effect.orDie,
               Effect.map((exists) => (exists ? s : undefined)),
             ),
@@ -329,7 +329,7 @@ export namespace Project {
         if (input.icon?.override) return
         if (input.icon?.url) return
 
-        const matches = yield* fsys
+        const matches = yield* fs
           .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
             cwd: input.worktree,
             absolute: true,
@@ -339,7 +339,7 @@ export namespace Project {
         const shortest = matches.sort((a, b) => a.length - b.length)[0]
         if (!shortest) return
 
-        const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
+        const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
         const base64 = Buffer.from(buffer).toString("base64")
         const mime = AppFileSystem.mimeType(shortest)
         const url = `data:${mime};base64,${base64}`
@@ -400,7 +400,7 @@ export namespace Project {
         return yield* Effect.forEach(
           data.sandboxes,
           (dir) =>
-            fsys.isDir(dir).pipe(
+            fs.isDir(dir).pipe(
               Effect.orDie,
               Effect.map((ok) => (ok ? dir : undefined)),
             ),
@@ -457,9 +457,8 @@ export namespace Project {
   )
 
   export const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodePath.layer),
   )
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 1 - 1
packages/opencode/src/pty/index.ts

@@ -273,7 +273,7 @@ export namespace Pty {
         if (input.size) {
           session.process.resize(input.size.cols, input.size.rows)
         }
-        yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
+        void Bus.publish(Event.Updated, { info: session.info })
         return session.info
       })
 

+ 53 - 50
packages/opencode/src/snapshot/index.ts

@@ -60,24 +60,28 @@ export namespace Snapshot {
 
   export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
 
-  export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
-    Layer.effect(
-      Service,
-      Effect.gen(function* () {
-        const fs = yield* AppFileSystem.Service
-        const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
-        const locks = new Map<string, Semaphore.Semaphore>()
-
-        const lock = (key: string) => {
-          const hit = locks.get(key)
-          if (hit) return hit
-
-          const next = Semaphore.makeUnsafe(1)
-          locks.set(key, next)
-          return next
-        }
-
-        const state = yield* InstanceState.make<State>(
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
+  > = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const fs = yield* AppFileSystem.Service
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const config = yield* Config.Service
+      const locks = new Map<string, Semaphore.Semaphore>()
+
+      const lock = (key: string) => {
+        const hit = locks.get(key)
+        if (hit) return hit
+
+        const next = Semaphore.makeUnsafe(1)
+        locks.set(key, next)
+        return next
+      }
+
+      const state = yield* InstanceState.make<State>(
           Effect.fn("Snapshot.state")(function* (ctx) {
             const state = {
               directory: ctx.directory,
@@ -123,7 +127,7 @@ export namespace Snapshot {
 
             const enabled = Effect.fnUntraced(function* () {
               if (state.vcs !== "git") return false
-              return (yield* Effect.promise(() => Config.get())).snapshot !== false
+              return (yield* config.get()).snapshot !== false
             })
 
             const excludes = Effect.fnUntraced(function* () {
@@ -423,40 +427,39 @@ export namespace Snapshot {
           }),
         )
 
-        return Service.of({
-          init: Effect.fn("Snapshot.init")(function* () {
-            yield* InstanceState.get(state)
-          }),
-          cleanup: Effect.fn("Snapshot.cleanup")(function* () {
-            return yield* InstanceState.useEffect(state, (s) => s.cleanup())
-          }),
-          track: Effect.fn("Snapshot.track")(function* () {
-            return yield* InstanceState.useEffect(state, (s) => s.track())
-          }),
-          patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
-          }),
-          restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
-          }),
-          revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
-            return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
-          }),
-          diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
-          }),
-          diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
-            return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
-          }),
-        })
-      }),
-    )
+      return Service.of({
+        init: Effect.fn("Snapshot.init")(function* () {
+          yield* InstanceState.get(state)
+        }),
+        cleanup: Effect.fn("Snapshot.cleanup")(function* () {
+          return yield* InstanceState.useEffect(state, (s) => s.cleanup())
+        }),
+        track: Effect.fn("Snapshot.track")(function* () {
+          return yield* InstanceState.useEffect(state, (s) => s.track())
+        }),
+        patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
+        }),
+        restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
+        }),
+        revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
+          return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
+        }),
+        diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
+        }),
+        diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
+          return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
+        }),
+      })
+    }),
+  )
 
   export const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
-    Layer.provide(NodePath.layer),
+    Layer.provide(Config.defaultLayer),
   )
 
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 16 - 13
packages/opencode/src/worktree/index.ts

@@ -11,9 +11,10 @@ import { Log } from "../util/log"
 import { Slug } from "@opencode-ai/util/slug"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
-import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
+import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { NodePath } from "@effect/platform-node"
+import { AppFileSystem } from "@/filesystem"
 import { makeRuntime } from "@/effect/run-service"
 import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
@@ -167,14 +168,15 @@ export namespace Worktree {
   export const layer: Layer.Layer<
     Service,
     never,
-    FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+    AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service
   > = Layer.effect(
     Service,
     Effect.gen(function* () {
       const scope = yield* Scope.Scope
-      const fsys = yield* FileSystem.FileSystem
+      const fs = yield* AppFileSystem.Service
       const pathSvc = yield* Path.Path
       const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const project = yield* Project.Service
 
       const git = Effect.fnUntraced(
         function* (args: string[], opts?: { cwd?: string }) {
@@ -201,7 +203,7 @@ export namespace Worktree {
           const branch = `opencode/${name}`
           const directory = pathSvc.join(root, name)
 
-          if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
+          if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
 
           const ref = `refs/heads/${branch}`
           const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
@@ -218,7 +220,7 @@ export namespace Worktree {
         }
 
         const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
-        yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
+        yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
 
         const base = name ? slugify(name) : ""
         return yield* candidate(root, base || undefined)
@@ -232,7 +234,7 @@ export namespace Worktree {
           throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
         }
 
-        yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
+        yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
       })
 
       const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
@@ -297,7 +299,7 @@ export namespace Worktree {
 
       const canonical = Effect.fnUntraced(function* (input: string) {
         const abs = pathSvc.resolve(input)
-        const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
+        const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
         const normalized = pathSvc.normalize(real)
         return process.platform === "win32" ? normalized.toLowerCase() : normalized
       })
@@ -334,7 +336,7 @@ export namespace Worktree {
       })
 
       function stopFsmonitor(target: string) {
-        return fsys.exists(target).pipe(
+        return fs.exists(target).pipe(
           Effect.orDie,
           Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
         )
@@ -364,7 +366,7 @@ export namespace Worktree {
         const entry = yield* locateWorktree(entries, directory)
 
         if (!entry?.path) {
-          const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
+          const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
           if (directoryExists) {
             yield* stopFsmonitor(directory)
             yield* cleanDirectory(directory)
@@ -464,7 +466,7 @@ export namespace Worktree {
               const target = yield* canonical(pathSvc.resolve(root, entry))
               if (target === base) return
               if (!target.startsWith(`${base}${pathSvc.sep}`)) return
-              yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
+              yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
             }),
           { concurrency: "unbounded" },
         )
@@ -603,8 +605,9 @@ export namespace Worktree {
   )
 
   const defaultLayer = layer.pipe(
-    Layer.provide(CrossSpawnSpawner.layer),
-    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(CrossSpawnSpawner.defaultLayer),
+    Layer.provide(Project.defaultLayer),
+    Layer.provide(AppFileSystem.defaultLayer),
     Layer.provide(NodePath.layer),
   )
   const { runPromise } = makeRuntime(Service, defaultLayer)

+ 99 - 114
packages/opencode/test/config/config.test.ts

@@ -1,9 +1,19 @@
 import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
+import { Effect, Layer, Option } from "effect"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Config } from "../../src/config/config"
 import { Instance } from "../../src/project/instance"
 import { Auth } from "../../src/auth"
 import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
+import { AppFileSystem } from "../../src/filesystem"
+import { provideTmpdirInstance } from "../fixture/fixture"
 import { tmpdir } from "../fixture/fixture"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+
+/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
+const infra = CrossSpawnSpawner.defaultLayer.pipe(
+  Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
+)
 import path from "path"
 import fs from "fs/promises"
 import { pathToFileURL } from "url"
@@ -12,6 +22,14 @@ import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "../../src/util/filesystem"
 import { BunProc } from "../../src/bun"
 
+const emptyAccount = Layer.mock(Account.Service)({
+  active: () => Effect.succeed(Option.none()),
+})
+
+const emptyAuth = Layer.mock(Auth.Service)({
+  all: () => Effect.succeed({}),
+})
+
 // Get managed config directory from environment (set in preload.ts)
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 
@@ -246,43 +264,44 @@ test("preserves env variables when adding $schema to config", async () => {
 })
 
 test("resolves env templates in account config with account token", async () => {
-  const originalActive = Account.active
-  const originalConfig = Account.config
-  const originalToken = Account.token
   const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
 
-  Account.active = mock(async () => ({
-    id: AccountID.make("account-1"),
-    email: "[email protected]",
-    url: "https://control.example.com",
-    active_org_id: OrgID.make("org-1"),
-  }))
-
-  Account.config = mock(async () => ({
-    provider: {
-      opencode: {
-        options: {
-          apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
-        },
-      },
-    },
-  }))
+  const fakeAccount = Layer.mock(Account.Service)({
+    active: () =>
+      Effect.succeed(
+        Option.some({
+          id: AccountID.make("account-1"),
+          email: "[email protected]",
+          url: "https://control.example.com",
+          active_org_id: OrgID.make("org-1"),
+        }),
+      ),
+    config: () =>
+      Effect.succeed(
+        Option.some({
+          provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
+        }),
+      ),
+    token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
+  })
 
-  Account.token = mock(async () => AccessToken.make("st_test_token"))
+  const layer = Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(emptyAuth),
+    Layer.provide(fakeAccount),
+    Layer.provideMerge(infra),
+  )
 
   try {
-    await using tmp = await tmpdir()
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const config = await Config.get()
-        expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
-      },
-    })
+    await provideTmpdirInstance(() =>
+      Config.Service.use((svc) =>
+        Effect.gen(function* () {
+          const config = yield* svc.get()
+          expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
+        }),
+      ),
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
-    Account.active = originalActive
-    Account.config = originalConfig
-    Account.token = originalToken
     if (originalControlToken !== undefined) {
       process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
     } else {
@@ -1588,7 +1607,7 @@ test("local .opencode config can override MCP from project config", async () =>
 test("project config overrides remote well-known config", async () => {
   const originalFetch = globalThis.fetch
   let fetchedUrl: string | undefined
-  const mockFetch = mock((url: string | URL | Request) => {
+  globalThis.fetch = mock((url: string | URL | Request) => {
     const urlStr = url.toString()
     if (urlStr.includes(".well-known/opencode")) {
       fetchedUrl = urlStr
@@ -1596,13 +1615,7 @@ test("project config overrides remote well-known config", async () => {
         new Response(
           JSON.stringify({
             config: {
-              mcp: {
-                jira: {
-                  type: "remote",
-                  url: "https://jira.example.com/mcp",
-                  enabled: false,
-                },
-              },
+              mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
             },
           }),
           { status: 200 },
@@ -1610,60 +1623,46 @@ test("project config overrides remote well-known config", async () => {
       )
     }
     return originalFetch(url)
+  }) as unknown as typeof fetch
+
+  const fakeAuth = Layer.mock(Auth.Service)({
+    all: () =>
+      Effect.succeed({
+        "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
+      }),
   })
-  globalThis.fetch = mockFetch as unknown as typeof fetch
 
-  const originalAuthAll = Auth.all
-  Auth.all = mock(() =>
-    Promise.resolve({
-      "https://example.com": {
-        type: "wellknown" as const,
-        key: "TEST_TOKEN",
-        token: "test-token",
-      },
-    }),
+  const layer = Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(fakeAuth),
+    Layer.provide(emptyAccount),
+    Layer.provideMerge(infra),
   )
 
   try {
-    await using tmp = await tmpdir({
-      git: true,
-      init: async (dir) => {
-        // Project config enables jira (overriding remote default)
-        await Filesystem.write(
-          path.join(dir, "opencode.json"),
-          JSON.stringify({
-            $schema: "https://opencode.ai/config.json",
-            mcp: {
-              jira: {
-                type: "remote",
-                url: "https://jira.example.com/mcp",
-                enabled: true,
-              },
-            },
+    await provideTmpdirInstance(
+      () =>
+        Config.Service.use((svc) =>
+          Effect.gen(function* () {
+            const config = yield* svc.get()
+            expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
+            expect(config.mcp?.jira?.enabled).toBe(true)
           }),
-        )
-      },
-    })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const config = await Config.get()
-        // Verify fetch was called for wellknown config
-        expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
-        // Project config (enabled: true) should override remote (enabled: false)
-        expect(config.mcp?.jira?.enabled).toBe(true)
+        ),
+      {
+        git: true,
+        config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
       },
-    })
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
     globalThis.fetch = originalFetch
-    Auth.all = originalAuthAll
   }
 })
 
 test("wellknown URL with trailing slash is normalized", async () => {
   const originalFetch = globalThis.fetch
   let fetchedUrl: string | undefined
-  const mockFetch = mock((url: string | URL | Request) => {
+  globalThis.fetch = mock((url: string | URL | Request) => {
     const urlStr = url.toString()
     if (urlStr.includes(".well-known/opencode")) {
       fetchedUrl = urlStr
@@ -1671,13 +1670,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
         new Response(
           JSON.stringify({
             config: {
-              mcp: {
-                slack: {
-                  type: "remote",
-                  url: "https://slack.example.com/mcp",
-                  enabled: true,
-                },
-              },
+              mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
             },
           }),
           { status: 200 },
@@ -1685,43 +1678,35 @@ test("wellknown URL with trailing slash is normalized", async () => {
       )
     }
     return originalFetch(url)
+  }) as unknown as typeof fetch
+
+  const fakeAuth = Layer.mock(Auth.Service)({
+    all: () =>
+      Effect.succeed({
+        "https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
+      }),
   })
-  globalThis.fetch = mockFetch as unknown as typeof fetch
 
-  const originalAuthAll = Auth.all
-  Auth.all = mock(() =>
-    Promise.resolve({
-      "https://example.com/": {
-        type: "wellknown" as const,
-        key: "TEST_TOKEN",
-        token: "test-token",
-      },
-    }),
+  const layer = Config.layer.pipe(
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(fakeAuth),
+    Layer.provide(emptyAccount),
+    Layer.provideMerge(infra),
   )
 
   try {
-    await using tmp = await tmpdir({
-      git: true,
-      init: async (dir) => {
-        await Filesystem.write(
-          path.join(dir, "opencode.json"),
-          JSON.stringify({
-            $schema: "https://opencode.ai/config.json",
+    await provideTmpdirInstance(
+      () =>
+        Config.Service.use((svc) =>
+          Effect.gen(function* () {
+            yield* svc.get()
+            expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
           }),
-        )
-      },
-    })
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        await Config.get()
-        // Trailing slash should be stripped — no double slash in the fetch URL
-        expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
-      },
-    })
+        ),
+      { git: true },
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
     globalThis.fetch = originalFetch
-    Auth.all = originalAuthAll
   }
 })
 

+ 1 - 1
packages/opencode/test/effect/cross-spawn-spawner.test.ts

@@ -9,7 +9,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
 import { tmpdir } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 
-const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
+const live = CrossSpawnSpawner.defaultLayer
 const fx = testEffect(live)
 
 function js(code: string, opts?: ChildProcess.CommandOptions) {

+ 2 - 0
packages/opencode/test/file/watcher.test.ts

@@ -5,6 +5,7 @@ import path from "path"
 import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect"
 import { tmpdir } from "../fixture/fixture"
 import { Bus } from "../../src/bus"
+import { Config } from "../../src/config/config"
 import { FileWatcher } from "../../src/file/watcher"
 import { Instance } from "../../src/project/instance"
 
@@ -30,6 +31,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
     directory,
     fn: async () => {
       const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
+        Layer.provide(Config.defaultLayer),
         Layer.provide(watcherConfigLayer),
       )
       const rt = ManagedRuntime.make(layer)

+ 2 - 1
packages/opencode/test/format/format.test.ts

@@ -4,13 +4,14 @@ import { Effect, Layer } from "effect"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { Format } from "../../src/format"
+import { Config } from "../../src/config/config"
 import * as Formatter from "../../src/format/formatter"
 
 const node = NodeChildProcessSpawner.layer.pipe(
   Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
 )
 
-const it = testEffect(Layer.mergeAll(Format.layer, node))
+const it = testEffect(Layer.mergeAll(Format.layer, node).pipe(Layer.provide(Config.defaultLayer)))
 
 describe("Format", () => {
   it.effect("status() returns built-in formatters when no config overrides", () =>

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

@@ -47,7 +47,7 @@ function mockGitFailure(failArg: string) {
         }),
       )
     }),
-  ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
+  ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
 }
 
 function projectLayerWithFailure(failArg: string) {