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

fix(instance): run bootstrap from instance store (#25475)

Kit Langton 3 месяцев назад
Родитель
Сommit
f98053c34e
42 измененных файлов с 540 добавлено и 249 удалено
  1. 2 4
      packages/opencode/src/cli/bootstrap.ts
  2. 12 5
      packages/opencode/src/cli/cmd/tui/worker.ts
  3. 6 30
      packages/opencode/src/config/config.ts
  4. 3 17
      packages/opencode/src/effect/app-runtime.ts
  5. 9 0
      packages/opencode/src/project/bootstrap-service.ts
  6. 6 8
      packages/opencode/src/project/bootstrap.ts
  7. 27 0
      packages/opencode/src/project/instance-runtime.ts
  8. 4 11
      packages/opencode/src/project/instance-store.ts
  9. 2 4
      packages/opencode/src/project/instance.ts
  10. 37 0
      packages/opencode/src/server/global-lifecycle.ts
  11. 10 14
      packages/opencode/src/server/routes/global.ts
  12. 4 1
      packages/opencode/src/server/routes/instance/config.ts
  13. 1 0
      packages/opencode/src/server/routes/instance/httpapi/groups/global.ts
  14. 1 1
      packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts
  15. 7 8
      packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts
  16. 3 7
      packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts
  17. 2 4
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  18. 2 3
      packages/opencode/src/server/routes/instance/index.ts
  19. 0 2
      packages/opencode/src/server/routes/instance/middleware.ts
  20. 2 8
      packages/opencode/src/server/routes/instance/project.ts
  21. 1 3
      packages/opencode/src/server/workspace.ts
  22. 51 0
      packages/opencode/test/agent/plugin-agent-regression.test.ts
  23. 32 21
      packages/opencode/test/config/config.test.ts
  24. 7 4
      packages/opencode/test/config/tui.test.ts
  25. 3 4
      packages/opencode/test/effect/instance-state.test.ts
  26. 23 0
      packages/opencode/test/fixture/config.ts
  27. 33 14
      packages/opencode/test/fixture/fixture.ts
  28. 2 2
      packages/opencode/test/mcp/lifecycle.test.ts
  29. 11 5
      packages/opencode/test/permission/next.test.ts
  30. 52 21
      packages/opencode/test/plugin/auth-override.test.ts
  31. 29 11
      packages/opencode/test/plugin/loader-shared.test.ts
  32. 85 0
      packages/opencode/test/project/instance-bootstrap-regression.test.ts
  33. 6 1
      packages/opencode/test/project/instance.test.ts
  34. 9 7
      packages/opencode/test/project/worktree.test.ts
  35. 3 3
      packages/opencode/test/question/question.test.ts
  36. 2 4
      packages/opencode/test/server/httpapi-instance-context.test.ts
  37. 2 2
      packages/opencode/test/server/httpapi-mcp.test.ts
  38. 2 2
      packages/opencode/test/server/httpapi-provider.test.ts
  39. 2 1
      packages/opencode/test/session/compaction.test.ts
  40. 2 15
      packages/opencode/test/session/instruction.test.ts
  41. 41 1
      packages/opencode/test/tool/registry.test.ts
  42. 2 1
      packages/opencode/test/tool/truncation.test.ts

+ 2 - 4
packages/opencode/src/cli/bootstrap.ts

@@ -1,17 +1,15 @@
 import { Instance } from "../project/instance"
-import { InstanceStore } from "../project/instance-store"
-import { getBootstrapRunEffect } from "../effect/app-runtime"
+import { InstanceRuntime } from "../project/instance-runtime"
 
 export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
   return Instance.provide({
     directory,
-    init: await getBootstrapRunEffect(),
     fn: async () => {
       try {
         const result = await cb()
         return result
       } finally {
-        await InstanceStore.disposeInstance(Instance.current)
+        await InstanceRuntime.disposeInstance(Instance.current)
       }
     },
   })

+ 12 - 5
packages/opencode/src/cli/cmd/tui/worker.ts

@@ -2,7 +2,7 @@ import { Installation } from "@/installation"
 import { Server } from "@/server/server"
 import * as Log from "@opencode-ai/core/util/log"
 import { Instance } from "@/project/instance"
-import { InstanceStore } from "@/project/instance-store"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { Rpc } from "@/util/rpc"
 import { upgrade } from "@/cli/upgrade"
 import { Config } from "@/config/config"
@@ -10,8 +10,10 @@ import { GlobalBus } from "@/bus/global"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { writeHeapSnapshot } from "node:v8"
 import { Heap } from "@/cli/heap"
-import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
+import { AppRuntime } from "@/effect/app-runtime"
 import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
+import { Effect } from "effect"
+import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
 
 ensureProcessMetadata("worker")
 
@@ -77,19 +79,24 @@ export const rpc = {
   async checkUpgrade(input: { directory: string }) {
     await Instance.provide({
       directory: input.directory,
-      init: await getBootstrapRunEffect(),
       fn: async () => {
         await upgrade().catch(() => {})
       },
     })
   },
   async reload() {
-    await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
+    await AppRuntime.runPromise(
+      Effect.gen(function* () {
+        const cfg = yield* Config.Service
+        yield* cfg.invalidate()
+        yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
+      }),
+    )
   },
   async shutdown() {
     Log.Default.info("worker shutting down")
 
-    await InstanceStore.disposeAllInstances()
+    await InstanceRuntime.disposeAllInstances()
     if (server) await server.stop(true)
   },
 }

+ 6 - 30
packages/opencode/src/config/config.ts

@@ -12,11 +12,8 @@ import { Auth } from "../auth"
 import { Env } from "../env"
 import { applyEdits, modify } from "jsonc-parser"
 import { type InstanceContext } from "../project/instance"
-import { InstanceStore } from "../project/instance-store"
 import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
 import { existsSync } from "fs"
-import { GlobalBus } from "@/bus/global"
-import { Event } from "../server/event"
 import { Account } from "@/account/account"
 import { isRecord } from "@/util/record"
 import type { ConsoleState } from "./console-state"
@@ -289,9 +286,9 @@ export interface Interface {
   readonly get: () => Effect.Effect<Info>
   readonly getGlobal: () => Effect.Effect<Info>
   readonly getConsoleState: () => Effect.Effect<ConsoleState>
-  readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
-  readonly updateGlobal: (config: Info) => Effect.Effect<Info>
-  readonly invalidate: (wait?: boolean) => Effect.Effect<void>
+  readonly update: (config: Info) => Effect.Effect<void>
+  readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
+  readonly invalidate: () => Effect.Effect<void>
   readonly directories: () => Effect.Effect<string[]>
   readonly waitForDependencies: () => Effect.Effect<void>
 }
@@ -730,37 +727,17 @@ export const layer = Layer.effect(
       )
     })
 
-    const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
+    const update = Effect.fn("Config.update")(function* (config: Info) {
       const dir = yield* InstanceState.directory
       const file = path.join(dir, "config.json")
       const existing = yield* loadFile(file)
       yield* fs
         .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
         .pipe(Effect.orDie)
-      if (options?.dispose !== false) {
-        // Fail loudly if no instance is bound — silently skipping would
-        // mask "config update without an active instance" bugs. The throw
-        // comes from `Instance.current` inside `InstanceState.context`.
-        const ctx = yield* InstanceState.context
-        yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
-      }
     })
 
-    const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
+    const invalidate = Effect.fn("Config.invalidate")(function* () {
       yield* invalidateGlobal
-      const task = InstanceStore.disposeAllInstances()
-        .catch(() => undefined)
-        .finally(() =>
-          GlobalBus.emit("event", {
-            directory: "global",
-            payload: {
-              type: Event.Disposed.type,
-              properties: {},
-            },
-          }),
-        )
-      if (wait) yield* Effect.promise(() => task)
-      else void task
     })
 
     const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
@@ -784,9 +761,8 @@ export const layer = Layer.effect(
         if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
       }
 
-      // Only tear down running instances if the config actually changed.
       if (changed) yield* invalidate()
-      return next
+      return { info: next, changed }
     })
 
     return Service.of({

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

@@ -1,4 +1,4 @@
-import { Effect, Layer, ManagedRuntime } from "effect"
+import { Layer, ManagedRuntime } from "effect"
 import { attach } from "./run-service"
 import * as Observability from "@opencode-ai/core/effect/observability"
 
@@ -40,8 +40,7 @@ import { Command } from "@/command"
 import { Truncate } from "@/tool/truncate"
 import { ToolRegistry } from "@/tool/registry"
 import { Format } from "@/format"
-import { InstanceBootstrap } from "@/project/bootstrap"
-import { InstanceStore } from "@/project/instance-store"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { Project } from "@/project/project"
 import { Vcs } from "@/project/vcs"
 import { Workspace } from "@/control-plane/workspace"
@@ -94,8 +93,7 @@ export const AppLayer = Layer.mergeAll(
   Truncate.defaultLayer,
   ToolRegistry.defaultLayer,
   Format.defaultLayer,
-  InstanceBootstrap.defaultLayer,
-  InstanceStore.defaultLayer,
+  InstanceRuntime.layer,
   Project.defaultLayer,
   Vcs.defaultLayer,
   Workspace.defaultLayer,
@@ -132,15 +130,3 @@ export const AppRuntime: Runtime = {
   },
   dispose: () => rt.dispose(),
 }
-
-let bootstrapRun: Promise<Effect.Effect<void>>
-export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
-  if (!bootstrapRun) {
-    bootstrapRun = AppRuntime.runPromise(
-      Effect.gen(function* () {
-        return (yield* InstanceBootstrap.Service).run
-      }),
-    )
-  }
-  return bootstrapRun
-}

+ 9 - 0
packages/opencode/src/project/bootstrap-service.ts

@@ -0,0 +1,9 @@
+import { Context, Effect } from "effect"
+
+export interface Interface {
+  readonly run: Effect.Effect<void>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
+
+export * as InstanceBootstrap from "./bootstrap-service"

+ 6 - 8
packages/opencode/src/project/bootstrap.ts

@@ -10,21 +10,19 @@ import { Command } from "../command"
 import { InstanceState } from "@/effect/instance-state"
 import { FileWatcher } from "@/file/watcher"
 import { ShareNext } from "@/share/share-next"
-import { Context, Effect, Layer } from "effect"
+import { Effect, Layer } from "effect"
 import { Config } from "@/config/config"
+import { Service } from "./bootstrap-service"
 
-export interface Interface {
-  readonly run: Effect.Effect<void>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
+export { Service } from "./bootstrap-service"
+export type { Interface } from "./bootstrap-service"
 
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
     // Yield each bootstrap dep at layer init so `run` itself has R = never.
-    // This breaks the circular declaration loop through Config → Instance → InstanceStore
-    // (instance-store.ts only yields this Service tag, never the impl-side services).
+    // InstanceStore imports only the lightweight tag from bootstrap-service.ts,
+    // so it can depend on bootstrap without importing this implementation graph.
     const bus = yield* Bus.Service
     const config = yield* Config.Service
     const file = yield* File.Service

+ 27 - 0
packages/opencode/src/project/instance-runtime.ts

@@ -0,0 +1,27 @@
+import { makeRuntime } from "@/effect/run-service"
+import { type InstanceContext } from "./instance-context"
+import { InstanceStore, type LoadInput } from "./instance-store"
+import { Effect, Layer } from "effect"
+
+// Production InstanceStore wiring plus a bridge for Promise/ALS callers that
+// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself
+// low-level while still giving legacy Hono and CLI paths the production
+// bootstrap implementation. Delete the Promise helpers once those callers are
+// migrated to Effect boundaries that provide InstanceStore directly.
+// Keep the bootstrap implementation import lazy: Instance is imported broadly,
+// and importing the app bootstrap graph at module load can trigger ESM cycles.
+export const layer = Layer.unwrap(
+  Effect.promise(async () => {
+    const { InstanceBootstrap } = await import("./bootstrap")
+    return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
+  }),
+)
+
+const runtime = makeRuntime(InstanceStore.Service, layer)
+
+export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input))
+export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
+export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
+export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
+
+export * as InstanceRuntime from "./instance-runtime"

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

@@ -2,10 +2,10 @@ import { GlobalBus } from "@/bus/global"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { InstanceRef } from "@/effect/instance-ref"
 import { disposeInstance as runDisposers } from "@/effect/instance-registry"
-import { makeRuntime } from "@/effect/run-service"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
 import { type InstanceContext } from "./instance-context"
+import { InstanceBootstrap } from "./bootstrap-service"
 import * as Project from "./project"
 
 export interface LoadInput<R = never> {
@@ -36,10 +36,11 @@ interface Entry {
   readonly deferred: Deferred.Deferred<InstanceContext>
 }
 
-export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
+export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
   Service,
   Effect.gen(function* () {
     const project = yield* Project.Service
+    const bootstrap = yield* InstanceBootstrap.Service
     const scope = yield* Scope.Scope
     const cache = new Map<string, Entry>()
 
@@ -59,6 +60,7 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
                   project: result.project,
                 })),
               )
+        yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
         if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
         return ctx
       }).pipe(Effect.withSpan("InstanceStore.boot"))
@@ -195,13 +197,4 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
 
-export const runtime = makeRuntime(Service, defaultLayer)
-
-// Promise-returning helpers for callers without an Effect runtime in scope.
-// They route through `runtime` (not a yielded Service from a fresh runtime)
-// so they share the cache that `Instance.provide` populates.
-export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
-export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
-export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
-
 export * as InstanceStore from "./instance-store"

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

@@ -1,15 +1,13 @@
 import { Effect } from "effect"
 import { context, type InstanceContext } from "./instance-context"
-import { InstanceStore } from "./instance-store"
+import { InstanceRuntime } from "./instance-runtime"
 
 export type { InstanceContext } from "./instance-context"
 export type { LoadInput } from "./instance-store"
 
 export const Instance = {
   async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
-    const ctx = await InstanceStore.runtime.runPromise((store) =>
-      store.load({ directory: input.directory, init: input.init }),
-    )
+    const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init })
     return context.provide(ctx, async () => input.fn())
   },
   get current() {

+ 37 - 0
packages/opencode/src/server/global-lifecycle.ts

@@ -0,0 +1,37 @@
+import { GlobalBus } from "@/bus/global"
+import { InstanceStore } from "@/project/instance-store"
+import * as Log from "@opencode-ai/core/util/log"
+import { Effect } from "effect"
+import { Event } from "./event"
+
+const log = Log.create({ service: "server" })
+
+export const emitGlobalDisposed = Effect.sync(() =>
+  GlobalBus.emit("event", {
+    directory: "global",
+    payload: {
+      type: Event.Disposed.type,
+      properties: {},
+    },
+  }),
+)
+
+export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn(
+  "Server.disposeAllInstancesAndEmitGlobalDisposed",
+)(function* (options?: { swallowErrors?: boolean }) {
+  const store = yield* InstanceStore.Service
+  yield* Effect.gen(function* () {
+    yield* (options?.swallowErrors
+      ? store.disposeAll().pipe(
+          Effect.catchCause((cause) =>
+            Effect.sync(() => {
+              log.warn("global disposal failed", { cause })
+            }),
+          ),
+        )
+      : store.disposeAll())
+    yield* emitGlobalDisposed
+  }).pipe(Effect.uninterruptible)
+})
+
+export * as GlobalLifecycle from "./global-lifecycle"

+ 10 - 14
packages/opencode/src/server/routes/global.ts

@@ -1,25 +1,23 @@
 import { Hono, type Context } from "hono"
 import { describeRoute, resolver, validator } from "hono-openapi"
 import { streamSSE } from "hono/streaming"
-import { Effect, Schema } from "effect"
+import { Effect } from "effect"
 import z from "zod"
 import { BusEvent } from "@/bus/bus-event"
 import { SyncEvent } from "@/sync"
 import { GlobalBus } from "@/bus/global"
 import { AppRuntime } from "@/effect/app-runtime"
 import { AsyncQueue } from "@/util/queue"
-import { InstanceStore } from "../../project/instance-store"
 import { Installation } from "@/installation"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import * as Log from "@opencode-ai/core/util/log"
 import { lazy } from "../../util/lazy"
 import { Config } from "@/config/config"
 import { errors } from "../error"
+import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle"
 
 const log = Log.create({ service: "server" })
 
-export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({}))
-
 async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
   return streamSSE(c, async (stream) => {
     const q = new AsyncQueue<string | null>()
@@ -178,8 +176,13 @@ export const GlobalRoutes = lazy(() =>
       validator("json", Config.Info.zod),
       async (c) => {
         const config = c.req.valid("json")
-        const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
-        return c.json(next)
+        const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
+        if (result.changed) {
+          void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch(
+            () => undefined,
+          )
+        }
+        return c.json(result.info)
       },
     )
     .post(
@@ -200,14 +203,7 @@ export const GlobalRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        await InstanceStore.disposeAllInstances()
-        GlobalBus.emit("event", {
-          directory: "global",
-          payload: {
-            type: GlobalDisposedEvent.type,
-            properties: {},
-          },
-        })
+        await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed())
         return c.json(true)
       },
     )

+ 4 - 1
packages/opencode/src/server/routes/instance/config.ts

@@ -1,7 +1,8 @@
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
-import z from "zod"
 import { Config } from "@/config/config"
+import { InstanceState } from "@/effect/instance-state"
+import { InstanceStore } from "@/project/instance-store"
 import { Provider } from "@/provider/provider"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
@@ -55,7 +56,9 @@ export const ConfigRoutes = lazy(() =>
         jsonRequest("ConfigRoutes.update", c, function* () {
           const config = c.req.valid("json")
           const cfg = yield* Config.Service
+          const store = yield* InstanceStore.Service
           yield* cfg.update(config)
+          yield* store.dispose(yield* InstanceState.context)
           return config
         }),
     )

+ 1 - 0
packages/opencode/src/server/routes/instance/httpapi/groups/global.ts

@@ -1,6 +1,7 @@
 import { Config } from "@/config/config"
 import { BusEvent } from "@/bus/bus-event"
 import { SyncEvent } from "@/sync"
+import "@/server/event"
 import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { described } from "./metadata"

+ 1 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts

@@ -16,7 +16,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
     })
 
     const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
-      yield* configSvc.update(ctx.payload, { dispose: false })
+      yield* configSvc.update(ctx.payload)
       yield* markInstanceForDisposal(yield* InstanceState.context)
       return ctx.payload
     })

+ 7 - 8
packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts

@@ -1,7 +1,8 @@
 import { Config } from "@/config/config"
 import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
+import { EffectBridge } from "@/effect/bridge"
 import { Installation } from "@/installation"
-import { InstanceStore } from "@/project/instance-store"
+import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
 import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import * as Log from "@opencode-ai/core/util/log"
 import { Effect, Queue, Schema } from "effect"
@@ -68,7 +69,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
   Effect.gen(function* () {
     const config = yield* Config.Service
     const installation = yield* Installation.Service
-    const store = yield* InstanceStore.Service
+    const bridge = yield* EffectBridge.make()
 
     const health = Effect.fn("GlobalHttpApi.health")(function* () {
       return { healthy: true as const, version: InstallationVersion }
@@ -83,15 +84,13 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
     })
 
     const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
-      return yield* config.updateGlobal(ctx.payload)
+      const result = yield* config.updateGlobal(ctx.payload)
+      if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }))
+      return result.info
     })
 
     const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
-      yield* store.disposeAll()
-      GlobalBus.emit("event", {
-        directory: "global",
-        payload: { type: "global.disposed", properties: {} },
-      })
+      yield* disposeAllInstancesAndEmitGlobalDisposed()
       return true
     })
 

+ 3 - 7
packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts

@@ -1,5 +1,4 @@
 import { WorkspaceRef } from "@/effect/instance-ref"
-import { InstanceBootstrap } from "@/project/bootstrap"
 import { InstanceStore } from "@/project/instance-store"
 import { Effect, Layer } from "effect"
 import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -24,12 +23,11 @@ function decode(input: string): string {
 function provideInstanceContext<E>(
   effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
   store: InstanceStore.Interface,
-  bootstrap: InstanceBootstrap.Interface,
 ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
   return Effect.gen(function* () {
     const route = yield* WorkspaceRouteContext
     return yield* store.provide(
-      { directory: decode(route.directory), init: bootstrap.run },
+      { directory: decode(route.directory) },
       effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
     )
   })
@@ -39,15 +37,13 @@ export const instanceContextLayer = Layer.effect(
   InstanceContextMiddleware,
   Effect.gen(function* () {
     const store = yield* InstanceStore.Service
-    const bootstrap = yield* InstanceBootstrap.Service
-    return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap))
+    return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
   }),
 )
 
 export const instanceRouterMiddleware = HttpRouter.middleware()(
   Effect.gen(function* () {
     const store = yield* InstanceStore.Service
-    const bootstrap = yield* InstanceBootstrap.Service
-    return (effect) => provideInstanceContext(effect, store, bootstrap)
+    return (effect) => provideInstanceContext(effect, store)
   }),
 )

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

@@ -18,8 +18,7 @@ import { LSP } from "@/lsp/lsp"
 import { MCP } from "@/mcp"
 import { Permission } from "@/permission"
 import { Installation } from "@/installation"
-import { InstanceBootstrap } from "@/project/bootstrap"
-import { InstanceStore } from "@/project/instance-store"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { Plugin } from "@/plugin"
 import { Project } from "@/project/project"
 import { ProviderAuth } from "@/provider/auth"
@@ -153,8 +152,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
       Format.defaultLayer,
       LSP.defaultLayer,
       Installation.defaultLayer,
-      InstanceBootstrap.defaultLayer,
-      InstanceStore.defaultLayer,
+      InstanceRuntime.layer,
       MCP.defaultLayer,
       ModelsDev.defaultLayer,
       Permission.defaultLayer,

+ 2 - 3
packages/opencode/src/server/routes/instance/index.ts

@@ -6,7 +6,7 @@ import z from "zod"
 import { Format } from "@/format"
 import { TuiRoutes } from "./tui"
 import { Instance } from "@/project/instance"
-import { InstanceStore } from "@/project/instance-store"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { Vcs } from "@/project/vcs"
 import { Agent } from "@/agent/agent"
 import { Skill } from "@/skill"
@@ -25,7 +25,6 @@ import { ExperimentalRoutes } from "./experimental"
 import { ProviderRoutes } from "./provider"
 import { EventRoutes } from "./event"
 import { SyncRoutes } from "./sync"
-import { InstanceMiddleware } from "./middleware"
 import { jsonRequest } from "./trace"
 
 export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
@@ -63,7 +62,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
         },
       }),
       async (c) => {
-        await InstanceStore.disposeInstance(Instance.current)
+        await InstanceRuntime.disposeInstance(Instance.current)
         return c.json(true)
       },
     )

+ 0 - 2
packages/opencode/src/server/routes/instance/middleware.ts

@@ -1,6 +1,5 @@
 import type { MiddlewareHandler } from "hono"
 import { Instance } from "@/project/instance"
-import { getBootstrapRunEffect } from "@/effect/app-runtime"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { WorkspaceID } from "@/control-plane/schema"
@@ -23,7 +22,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
       async fn() {
         return Instance.provide({
           directory,
-          init: await getBootstrapRunEffect(),
           async fn() {
             return next()
           },

+ 2 - 8
packages/opencode/src/server/routes/instance/project.ts

@@ -2,13 +2,12 @@ import { Hono } from "hono"
 import { describeRoute, validator } from "hono-openapi"
 import { resolver } from "hono-openapi"
 import { Instance } from "@/project/instance"
-import { InstanceStore } from "@/project/instance-store"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { Project } from "@/project/project"
 import z from "zod"
 import { ProjectID } from "@/project/schema"
 import { errors } from "../../error"
 import { lazy } from "@/util/lazy"
-import { getBootstrapRunEffect } from "@/effect/app-runtime"
 import { jsonRequest, runRequest } from "./trace"
 
 export const ProjectRoutes = lazy(() =>
@@ -82,12 +81,7 @@ export const ProjectRoutes = lazy(() =>
           Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
         )
         if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
-        await InstanceStore.reloadInstance({
-          directory: dir,
-          worktree: dir,
-          project: next,
-          init: await getBootstrapRunEffect(),
-        })
+        await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next })
         return c.json(next)
       },
     )

+ 1 - 3
packages/opencode/src/server/workspace.ts

@@ -5,7 +5,7 @@ import { WorkspaceID } from "@/control-plane/schema"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
 import { Workspace } from "@/control-plane/workspace"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Instance } from "@/project/instance"
 import { Session } from "@/session/session"
 import { SessionID } from "@/session/schema"
@@ -94,13 +94,11 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
     const target = await adapter.target(workspace)
 
     if (target.type === "local") {
-      const init = await getBootstrapRunEffect()
       return WorkspaceContext.provide({
         workspaceID: WorkspaceID.make(workspaceID),
         fn: () =>
           Instance.provide({
             directory: target.directory,
-            init,
             async fn() {
               return next()
             },

+ 51 - 0
packages/opencode/test/agent/plugin-agent-regression.test.ts

@@ -0,0 +1,51 @@
+import { afterEach, expect, test } from "bun:test"
+import path from "path"
+import { pathToFileURL } from "url"
+import { AppRuntime } from "../../src/effect/app-runtime"
+import { Agent } from "../../src/agent/agent"
+import { Instance } from "../../src/project/instance"
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+
+afterEach(async () => {
+  await disposeAllInstances()
+})
+
+test("plugin-registered agents appear in Agent.list", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const pluginFile = path.join(dir, "plugin.ts")
+      await Bun.write(
+        pluginFile,
+        [
+          "export default async () => ({",
+          "  config: async (cfg) => {",
+          "    cfg.agent = cfg.agent ?? {}",
+          "    cfg.agent.plugin_added = {",
+          '      description: "Added by a plugin via the config hook",',
+          '      mode: "subagent",',
+          "    }",
+          "  },",
+          "})",
+          "",
+        ].join("\n"),
+      )
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: [pathToFileURL(pluginFile).href],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
+      const added = agents.find((agent) => agent.name === "plugin_added")
+      expect(added?.description).toBe("Added by a plugin via the config hook")
+      expect(added?.mode).toBe("subagent")
+    },
+  })
+})

+ 32 - 21
packages/opencode/test/config/config.test.ts

@@ -12,8 +12,9 @@ import { Account } from "../../src/account/account"
 import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Env } from "../../src/env"
-import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
+import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture"
 import { tmpdir } from "../fixture/fixture"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { testEffect } from "../lib/effect"
 
@@ -41,6 +42,12 @@ const emptyAuth = Layer.mock(Auth.Service)({
 
 const testFlock = EffectFlock.defaultLayer
 
+const noopNpm = Layer.mock(Npm.Service)({
+  install: () => Effect.void,
+  add: () => Effect.die("not implemented"),
+  which: () => Effect.succeed(Option.none()),
+})
+
 const layer = Config.layer.pipe(
   Layer.provide(testFlock),
   Layer.provide(AppFileSystem.defaultLayer),
@@ -48,7 +55,7 @@ const layer = Config.layer.pipe(
   Layer.provide(emptyAuth),
   Layer.provide(emptyAccount),
   Layer.provideMerge(infra),
-  Layer.provide(Npm.defaultLayer),
+  Layer.provide(noopNpm),
 )
 
 const it = testEffect(layer)
@@ -57,9 +64,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe
 const save = (config: Config.Info) =>
   Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
 const saveGlobal = (config: Config.Info) =>
-  Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
-const clear = (wait = false) =>
-  Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
+  Effect.runPromise(
+    Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
+      Effect.map((result) => result.info),
+      Effect.scoped,
+      Effect.provide(layer),
+    ),
+  )
+const clear = async (wait = false) => {
+  await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
+  if (wait) await InstanceRuntime.disposeAllInstances()
+}
 const listDirs = () =>
   Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
 const ready = () =>
@@ -108,7 +123,7 @@ async function check(map: (dir: string) => string) {
       },
     })
   } finally {
-    await disposeAllInstances()
+    await InstanceRuntime.disposeAllInstances()
     ;(Global.Path as { config: string }).config = prev
     await clear()
   }
@@ -483,6 +498,7 @@ test("resolves env templates in account config with account token", async () =>
     Layer.provide(emptyAuth),
     Layer.provide(fakeAccount),
     Layer.provideMerge(infra),
+    Layer.provide(noopNpm),
   )
 
   try {
@@ -493,7 +509,7 @@ test("resolves env templates in account config with account token", async () =>
           expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
         }),
       ),
-    ).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise)
+    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
   } finally {
     if (originalControlToken !== undefined) {
       process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
@@ -550,7 +566,7 @@ test("validates config schema and throws on invalid fields", async () => {
       })
     },
   })
-  await Instance.provide({
+  await provideTestInstance({
     directory: tmp.path,
     fn: async () => {
       // Strict schema should throw an error for invalid fields
@@ -565,7 +581,7 @@ test("throws error for invalid JSON", async () => {
       await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
     },
   })
-  await Instance.provide({
+  await provideTestInstance({
     directory: tmp.path,
     fn: async () => {
       await expect(load()).rejects.toThrow()
@@ -986,11 +1002,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
   const prev = process.env.OPENCODE_CONFIG_DIR
   process.env.OPENCODE_CONFIG_DIR = tmp.extra
 
-  const noopNpm = Layer.mock(Npm.Service)({
-    install: () => Effect.void,
-    add: () => Effect.die("not implemented"),
-    which: () => Effect.succeed(Option.none()),
-  })
   const testLayer = Config.layer.pipe(
     Layer.provide(testFlock),
     Layer.provide(AppFileSystem.defaultLayer),
@@ -1061,7 +1072,7 @@ test("resolves scoped npm plugins in config", async () => {
     },
   })
 
-  await Instance.provide({
+  await provideTestInstance({
     directory: tmp.path,
     fn: async () => {
       const config = await load()
@@ -1099,7 +1110,7 @@ test("merges plugin arrays from global and local configs", async () => {
     },
   })
 
-  await Instance.provide({
+  await provideTestInstance({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
       const config = await load()
@@ -1258,7 +1269,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
     },
   })
 
-  await Instance.provide({
+  await provideTestInstance({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
       const config = await load()
@@ -1307,7 +1318,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
     },
   })
 
-  await Instance.provide({
+  await provideTestInstance({
     directory: path.join(tmp.path, "project"),
     fn: async () => {
       const cfg = await load()
@@ -1883,7 +1894,7 @@ test("project config overrides remote well-known config", async () => {
     Layer.provide(fakeAuth),
     Layer.provide(emptyAccount),
     Layer.provideMerge(infra),
-    Layer.provide(Npm.defaultLayer),
+    Layer.provide(noopNpm),
   )
 
   try {
@@ -1941,7 +1952,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
     Layer.provide(fakeAuth),
     Layer.provide(emptyAccount),
     Layer.provideMerge(infra),
-    Layer.provide(Npm.defaultLayer),
+    Layer.provide(noopNpm),
   )
 
   try {
@@ -2096,7 +2107,7 @@ describe("deduplicatePluginOrigins", () => {
       },
     })
 
-    await Instance.provide({
+    await provideTestInstance({
       directory: path.join(tmp.path, "project"),
       fn: async () => {
         const config = await load()

+ 7 - 4
packages/opencode/test/config/tui.test.ts

@@ -1,8 +1,8 @@
 import { afterEach, beforeEach, expect, test } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
-import { tmpdir } from "../fixture/fixture"
-import { Instance } from "../../src/project/instance"
+import { provideTestInstance, tmpdir } from "../fixture/fixture"
+import { InstanceRuntime } from "@/project/instance-runtime"
 import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
 import { Config } from "@/config/config"
 import { Global } from "@opencode-ai/core/global"
@@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
 import { ConfigPlugin } from "@/config/plugin"
 
 const wintest = process.platform === "win32" ? test : test.skip
-const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
+const clear = async (wait = false) => {
+  await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
+  if (wait) await InstanceRuntime.disposeAllInstances()
+}
 const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
 
 beforeEach(async () => {
@@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
     },
   })
 
-  await Instance.provide({
+  await provideTestInstance({
     directory: tmp.path,
     fn: async () => {
       const server = await load()

+ 3 - 4
packages/opencode/test/effect/instance-state.test.ts

@@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { $ } from "bun"
 import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
 import { InstanceState } from "@/effect/instance-state"
-import { InstanceStore } from "../../src/project/instance-store"
 import { Instance } from "../../src/project/instance"
-import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
+import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 
 const it = testEffect(CrossSpawnSpawner.defaultLayer)
@@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () =>
     )
 
     const a = yield* access(state, dir)
-    yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
+    yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
     const b = yield* access(state, dir)
 
     expect(a).not.toBe(b)
@@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
 
       const [, b] = yield* Effect.all(
         [
-          Effect.promise(() => InstanceStore.reloadInstance({ directory: one })),
+          Effect.promise(() => reloadTestInstance({ directory: one })),
           Test.use((svc) => svc.get()).pipe(provideInstance(two)),
         ],
         { concurrency: "unbounded" },

+ 23 - 0
packages/opencode/test/fixture/config.ts

@@ -0,0 +1,23 @@
+import { Config } from "@/config/config"
+import { emptyConsoleState } from "@/config/console-state"
+import { Effect, Layer } from "effect"
+
+export function make(overrides: Partial<Config.Interface> = {}) {
+  return Config.Service.of({
+    get: () => Effect.succeed({}),
+    getGlobal: () => Effect.succeed({}),
+    getConsoleState: () => Effect.succeed(emptyConsoleState),
+    update: () => Effect.void,
+    updateGlobal: (config) => Effect.succeed({ info: config, changed: false }),
+    invalidate: () => Effect.void,
+    directories: () => Effect.succeed([]),
+    waitForDependencies: () => Effect.void,
+    ...overrides,
+  })
+}
+
+export function layer(overrides?: Partial<Config.Interface>) {
+  return Layer.succeed(Config.Service, make(overrides))
+}
+
+export * as TestConfig from "./config"

+ 33 - 14
packages/opencode/test/fixture/fixture.ts

@@ -1,20 +1,44 @@
 import { $ } from "bun"
+import * as Observability from "@opencode-ai/core/effect/observability"
 import * as fs from "fs/promises"
 import os from "os"
 import path from "path"
-import { Effect, Context } from "effect"
+import { Effect, Context, Layer, ManagedRuntime } from "effect"
 import type * as PlatformError from "effect/PlatformError"
 import type * as Scope from "effect/Scope"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import type { Config } from "@/config/config"
 import { InstanceRef } from "../../src/effect/instance-ref"
+import { InstanceBootstrap } from "../../src/project/bootstrap-service"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { InstanceStore } from "../../src/project/instance-store"
 import { Instance } from "../../src/project/instance"
 import { TestLLMServer } from "../lib/llm-server"
 
-// Re-export for test ergonomics. The implementation lives next to the runtime
-// it consumes; see `InstanceStore.disposeAllInstances` for the rationale.
-export { disposeAllInstances } from "../../src/project/instance-store"
+const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
+const testInstanceRuntime = ManagedRuntime.make(
+  InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)),
+)
+
+const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
+  testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
+
+export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
+  const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init }))
+  try {
+    return await Instance.restore(ctx, () => input.fn())
+  } finally {
+    await runTestInstanceStore((store) => store.dispose(ctx))
+  }
+}
+
+export async function reloadTestInstance(input: { directory: string }) {
+  return runTestInstanceStore((store) => store.reload(input))
+}
+
+export async function disposeAllInstances() {
+  await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
+}
 
 // Strip null bytes from paths (defensive fix for CI environment issues)
 function sanitizePath(p: string): string {
@@ -129,12 +153,10 @@ export const provideInstance =
   (directory: string) =>
   <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
     Effect.contextWith((services: Context.Context<R>) =>
-      Effect.promise<A>(async () =>
-        Instance.provide({
-          directory,
-          fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
-        }),
-      ),
+      Effect.promise<A>(async () => {
+        const ctx = await runTestInstanceStore((store) => store.load({ directory }))
+        return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))))
+      }),
     )
 
 export function provideTmpdirInstance<A, E, R>(
@@ -148,10 +170,7 @@ export function provideTmpdirInstance<A, E, R>(
     yield* Effect.addFinalizer(() =>
       provided
         ? Effect.promise(() =>
-            Instance.provide({
-              directory: path,
-              fn: () => InstanceStore.disposeInstance(Instance.current),
-            }),
+            runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))),
           ).pipe(Effect.ignore)
         : Effect.void,
     )

+ 2 - 2
packages/opencode/test/mcp/lifecycle.test.ts

@@ -1,5 +1,5 @@
 import { test, expect, mock, beforeEach } from "bun:test"
-import { InstanceStore } from "../../src/project/instance-store"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { Effect } from "effect"
 import type { MCP as MCPNS } from "../../src/mcp/index"
 
@@ -198,7 +198,7 @@ function withInstance(
       fn: async () => {
         await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
         // dispose instance to clean up state between tests
-        await InstanceStore.disposeInstance(Instance.current)
+        await InstanceRuntime.disposeInstance(Instance.current)
       },
     })
   }

+ 11 - 5
packages/opencode/test/permission/next.test.ts

@@ -6,8 +6,14 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Permission } from "../../src/permission"
 import { PermissionID } from "../../src/permission/schema"
 import { Instance } from "../../src/project/instance"
-import { InstanceStore } from "../../src/project/instance-store"
-import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
+import {
+  disposeAllInstances,
+  provideInstance,
+  provideTmpdirInstance,
+  reloadTestInstance,
+  tmpdirScoped,
+} from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { MessageID, SessionID } from "../../src/session/schema"
 
@@ -1000,7 +1006,7 @@ it.live("pending permission rejects on instance dispose", () =>
 
     expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
     yield* Effect.promise(() =>
-      Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }),
+      Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }),
     )
 
     const exit = yield* Fiber.await(fiber)
@@ -1024,7 +1030,7 @@ it.live("pending permission rejects on instance reload", () =>
     }).pipe(run, Effect.forkScoped)
 
     expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
-    yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
+    yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
 
     const exit = yield* Fiber.await(fiber)
     expect(Exit.isFailure(exit)).toBe(true)
@@ -1118,7 +1124,7 @@ it.live("ask - abort should clear pending request", () =>
 
     const pending = yield* waitForPending(1).pipe(run)
     expect(pending).toHaveLength(1)
-    yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
+    yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
 
     const exit = yield* Fiber.await(fiber)
     expect(Exit.isFailure(exit)).toBe(true)

+ 52 - 21
packages/opencode/test/plugin/auth-override.test.ts

@@ -1,11 +1,40 @@
 import { describe, expect, test } from "bun:test"
 import path from "path"
 import fs from "fs/promises"
-import { Effect } from "effect"
-import { tmpdir } from "../fixture/fixture"
-import { Instance } from "../../src/project/instance"
+import { pathToFileURL } from "url"
+import { Effect, Layer } from "effect"
+import { provideTestInstance, tmpdir } from "../fixture/fixture"
 import { ProviderAuth } from "@/provider/auth"
 import { ProviderID } from "../../src/provider/schema"
+import { Plugin } from "@/plugin"
+import { Auth } from "@/auth"
+import { Bus } from "@/bus"
+import { TestConfig } from "../fixture/config"
+
+function layer(directory: string, plugins: string[]) {
+  return ProviderAuth.layer.pipe(
+    Layer.provide(Auth.defaultLayer),
+    Layer.provide(
+      Plugin.layer.pipe(
+        Layer.provide(Bus.layer),
+        Layer.provide(
+          TestConfig.layer({
+            get: () =>
+              Effect.succeed({
+                plugin: plugins,
+                plugin_origins: plugins.map((plugin) => ({
+                  spec: plugin,
+                  source: path.join(directory, "opencode.json"),
+                  scope: "local" as const,
+                })),
+              }),
+            directories: () => Effect.succeed([directory]),
+          }),
+        ),
+      ),
+    ),
+  )
+}
 
 describe("plugin.auth-override", () => {
   test("user plugin overrides built-in github-copilot auth", async () => {
@@ -37,30 +66,32 @@ describe("plugin.auth-override", () => {
 
     await using plain = await tmpdir()
 
-    const methods = await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        return Effect.runPromise(
-          ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
-        )
-      },
-    })
-
-    const plainMethods = await Instance.provide({
-      directory: plain.path,
-      fn: async () => {
-        return Effect.runPromise(
-          ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
-        )
-      },
-    })
+    const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href
+    const [methods, plainMethods] = await Promise.all([
+      provideTestInstance({
+        directory: tmp.path,
+        fn: async () => {
+          return Effect.runPromise(
+            ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))),
+          )
+        },
+      }),
+      provideTestInstance({
+        directory: plain.path,
+        fn: async () => {
+          return Effect.runPromise(
+            ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))),
+          )
+        },
+      }),
+    ])
 
     const copilot = methods[ProviderID.make("github-copilot")]
     expect(copilot).toBeDefined()
     expect(copilot.length).toBe(1)
     expect(copilot[0].label).toBe("Test Override Auth")
     expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
-  }, 30000) // Increased timeout for plugin installation
+  }, 30000)
 })
 
 const file = path.join(import.meta.dir, "../../src/plugin/index.ts")

+ 29 - 11
packages/opencode/test/plugin/loader-shared.test.ts

@@ -1,9 +1,9 @@
 import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
-import { Effect } from "effect"
+import { Effect, Layer } from "effect"
 import fs from "fs/promises"
 import path from "path"
 import { pathToFileURL } from "url"
-import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
 import { Filesystem } from "@/util/filesystem"
 
 const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
@@ -12,8 +12,9 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
 const { Plugin } = await import("../../src/plugin/index")
 const { PluginLoader } = await import("../../src/plugin/loader")
 const { readPackageThemes } = await import("../../src/plugin/shared")
-const { Instance } = await import("../../src/project/instance")
+const { Bus } = await import("../../src/bus")
 const { Npm } = await import("@opencode-ai/core/npm")
+const { TestConfig } = await import("../fixture/config")
 
 afterAll(() => {
   if (disableDefault === undefined) {
@@ -28,14 +29,31 @@ afterEach(async () => {
 })
 
 async function load(dir: string) {
-  return Instance.provide({
-    directory: dir,
-    fn: async () =>
-      Effect.gen(function* () {
-        const plugin = yield* Plugin.Service
-        yield* plugin.list()
-      }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
-  })
+  const source = path.join(dir, "opencode.json")
+  const config = (await Bun.file(source).json()) as { plugin?: Array<string | [string, Record<string, unknown>]> }
+  const plugins = config.plugin ?? []
+  return Effect.gen(function* () {
+    const plugin = yield* Plugin.Service
+    yield* plugin.list()
+  }).pipe(
+    Effect.provide(
+      Plugin.layer.pipe(
+        Layer.provide(Bus.layer),
+        Layer.provide(
+          TestConfig.layer({
+            get: () =>
+              Effect.succeed({
+                plugin: plugins,
+                plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
+              }),
+            directories: () => Effect.succeed([dir]),
+          }),
+        ),
+      ),
+    ),
+    provideInstance(dir),
+    Effect.runPromise,
+  )
 }
 
 describe("plugin.loader.shared", () => {

+ 85 - 0
packages/opencode/test/project/instance-bootstrap-regression.test.ts

@@ -0,0 +1,85 @@
+import { afterEach, expect, test } from "bun:test"
+import { Hono } from "hono"
+import { existsSync } from "node:fs"
+import path from "node:path"
+import { pathToFileURL } from "node:url"
+import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
+import { Instance } from "../../src/project/instance"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
+import { InstanceMiddleware } from "../../src/server/routes/instance/middleware"
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+
+// These regressions cover the legacy instance-loading paths fixed by PRs
+// #25389 and #25449. The plugin config hook writes a marker file, and the test
+// bodies deliberately avoid touching Plugin or config directly. The marker only
+// exists if InstanceBootstrap ran at the instance boundary.
+
+afterEach(async () => {
+  await disposeAllInstances()
+})
+
+async function bootstrapFixture() {
+  return tmpdir({
+    init: async (dir) => {
+      const marker = path.join(dir, "config-hook-fired")
+      const pluginFile = path.join(dir, "plugin.ts")
+      await Bun.write(
+        pluginFile,
+        [
+          `const MARKER = ${JSON.stringify(marker)}`,
+          "export default async () => ({",
+          "  config: async () => {",
+          '    await Bun.write(MARKER, "ran")',
+          "  },",
+          "})",
+          "",
+        ].join("\n"),
+      )
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: [pathToFileURL(pluginFile).href],
+        }),
+      )
+      return marker
+    },
+  })
+}
+
+test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => {
+  await using tmp = await bootstrapFixture()
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => "ok",
+  })
+
+  expect(existsSync(tmp.extra)).toBe(true)
+})
+
+test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
+  await using tmp = await bootstrapFixture()
+
+  await cliBootstrap(tmp.path, async () => "ok")
+
+  expect(existsSync(tmp.extra)).toBe(true)
+})
+
+test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => {
+  await using tmp = await bootstrapFixture()
+  const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok"))
+
+  const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } })
+
+  expect(response.status).toBe(200)
+  expect(existsSync(tmp.extra)).toBe(true)
+})
+
+test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
+  await using tmp = await bootstrapFixture()
+
+  await InstanceRuntime.reloadInstance({ directory: tmp.path })
+
+  expect(existsSync(tmp.extra)).toBe(true)
+})

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

@@ -3,12 +3,17 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Effect, Fiber, Layer } from "effect"
 import { InstanceRef } from "../../src/effect/instance-ref"
 import { registerDisposer } from "../../src/effect/instance-registry"
+import { InstanceBootstrap } from "../../src/project/bootstrap-service"
 import { Instance } from "../../src/project/instance"
 import { InstanceStore } from "../../src/project/instance-store"
 import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 
-const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
+const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
+
+const it = testEffect(
+  Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)),
+)
 
 afterEach(async () => {
   await disposeAllInstances()

+ 9 - 7
packages/opencode/test/project/worktree.test.ts

@@ -5,7 +5,7 @@ import path from "path"
 import { Cause, Effect, Exit, Layer } from "effect"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { Instance } from "../../src/project/instance"
-import { InstanceStore } from "../../src/project/instance-store"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { Worktree } from "../../src/worktree"
 import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
@@ -138,9 +138,10 @@ describe("Worktree", () => {
             expect(props.branch).toBe(info.branch)
 
             yield* Effect.promise(() =>
-              InstanceStore.runtime.runPromise((s) =>
-                s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)),
-              ),
+              Instance.provide({
+                directory: info.directory,
+                fn: () => InstanceRuntime.disposeInstance(Instance.current),
+              }),
             )
             yield* Effect.promise(() => Bun.sleep(100))
             yield* svc.remove({ directory: info.directory })
@@ -162,9 +163,10 @@ describe("Worktree", () => {
 
             yield* Effect.promise(() => ready)
             yield* Effect.promise(() =>
-              InstanceStore.runtime.runPromise((s) =>
-                s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)),
-              ),
+              Instance.provide({
+                directory: info.directory,
+                fn: () => InstanceRuntime.disposeInstance(Instance.current),
+              }),
             )
             yield* Effect.promise(() => Bun.sleep(100))
             yield* svc.remove({ directory: info.directory })

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

@@ -1,7 +1,7 @@
 import { afterEach, test, expect } from "bun:test"
 import { Question } from "../../src/question"
 import { Instance } from "../../src/project/instance"
-import { InstanceStore } from "../../src/project/instance-store"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { QuestionID } from "../../src/question/schema"
 import { disposeAllInstances, tmpdir } from "../fixture/fixture"
 import { SessionID } from "../../src/session/schema"
@@ -422,7 +422,7 @@ test("pending question rejects on instance dispose", async () => {
     fn: async () => {
       const items = await list()
       expect(items).toHaveLength(1)
-      await InstanceStore.disposeInstance(Instance.current)
+      await InstanceRuntime.disposeInstance(Instance.current)
     },
   })
 
@@ -457,7 +457,7 @@ test("pending question rejects on instance reload", async () => {
     fn: async () => {
       const items = await list()
       expect(items).toHaveLength(1)
-      await InstanceStore.reloadInstance({ directory: tmp.path })
+      await InstanceRuntime.reloadInstance({ directory: tmp.path })
     },
   })
 

+ 2 - 4
packages/opencode/test/server/httpapi-instance-context.test.ts

@@ -11,9 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters"
 import type { WorkspaceAdapter } from "../../src/control-plane/types"
 import { Workspace } from "../../src/control-plane/workspace"
 import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
-import { InstanceBootstrap } from "../../src/project/bootstrap"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { Instance } from "../../src/project/instance"
-import { InstanceStore } from "../../src/project/instance-store"
 import { Project } from "../../src/project/project"
 import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
 import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -42,8 +41,7 @@ const it = testEffect(
     testStateLayer,
     NodeHttpServer.layerTest,
     NodeServices.layer,
-    InstanceBootstrap.defaultLayer,
-    InstanceStore.defaultLayer,
+    InstanceRuntime.layer,
     Project.defaultLayer,
     Workspace.defaultLayer,
   ),

+ 2 - 2
packages/opencode/test/server/httpapi-mcp.test.ts

@@ -5,7 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
 import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
 import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
 import { Instance } from "../../src/project/instance"
-import { InstanceStore } from "../../src/project/instance-store"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { Server } from "../../src/server/server"
 import * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
@@ -59,7 +59,7 @@ function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>)
     )
     yield* Effect.addFinalizer(() =>
       Effect.promise(() =>
-        Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
+        Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
       ).pipe(Effect.ignore),
     )
 

+ 2 - 2
packages/opencode/test/server/httpapi-provider.test.ts

@@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"
 import { NodeFileSystem, NodePath } from "@effect/platform-node"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Instance } from "../../src/project/instance"
-import { InstanceStore } from "../../src/project/instance-store"
+import { InstanceRuntime } from "../../src/project/instance-runtime"
 import { Server } from "../../src/server/server"
 import * as Log from "@opencode-ai/core/util/log"
 import { resetDatabase } from "../fixture/db"
@@ -91,7 +91,7 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
     yield* writeProviderAuthPlugin(dir)
     yield* Effect.addFinalizer(() =>
       Effect.promise(() =>
-        Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
+        Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
       ).pipe(Effect.ignore),
     )
 

+ 2 - 1
packages/opencode/test/session/compaction.test.ts

@@ -26,6 +26,7 @@ import { Snapshot } from "../../src/snapshot"
 import { ProviderTest } from "../fake/provider"
 import { testEffect } from "../lib/effect"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
+import { TestConfig } from "../fixture/config"
 
 void Log.init({ print: false })
 
@@ -208,7 +209,7 @@ function layer(result: "continue" | "compact") {
 
 function cfg(compaction?: Config.Info["compaction"]) {
   const base = Config.Info.zod.parse({})
-  return Layer.mock(Config.Service)({
+  return TestConfig.layer({
     get: () => Effect.succeed({ ...base, compaction }),
   })
 }

+ 2 - 15
packages/opencode/test/session/instruction.test.ts

@@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http"
 import { NodeFileSystem } from "@effect/platform-node"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
-import { Config } from "@/config/config"
-import { emptyConsoleState } from "@/config/console-state"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { Instruction } from "../../src/session/instruction"
 import type { MessageV2 } from "../../src/session/message-v2"
@@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
 import { Global } from "@opencode-ai/core/global"
 import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
+import { TestConfig } from "../fixture/config"
 
 const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
 
-const configLayer = Layer.succeed(
-  Config.Service,
-  Config.Service.of({
-    get: () => Effect.succeed({}),
-    getGlobal: () => Effect.succeed({}),
-    getConsoleState: () => Effect.succeed(emptyConsoleState),
-    update: () => Effect.void,
-    updateGlobal: (config) => Effect.succeed(config),
-    invalidate: () => Effect.void,
-    directories: () => Effect.succeed([]),
-    waitForDependencies: () => Effect.void,
-  }),
-)
+const configLayer = TestConfig.layer()
 
 const instructionLayer = (global: Partial<Global.Interface>) =>
   Instruction.layer.pipe(

+ 41 - 1
packages/opencode/test/tool/registry.test.ts

@@ -7,10 +7,50 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { ToolRegistry } from "@/tool/registry"
 import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
+import { TestConfig } from "../fixture/config"
+import { AppFileSystem } from "@opencode-ai/core/filesystem"
+import { Plugin } from "@/plugin"
+import { Question } from "@/question"
+import { Todo } from "@/session/todo"
+import { Skill } from "@/skill"
+import { Agent } from "@/agent/agent"
+import { Session } from "@/session/session"
+import { Provider } from "@/provider/provider"
+import { LSP } from "@/lsp/lsp"
+import { Instruction } from "@/session/instruction"
+import { Bus } from "@/bus"
+import { FetchHttpClient } from "effect/unstable/http"
+import { Format } from "@/format"
+import { Ripgrep } from "@/file/ripgrep"
+import * as Truncate from "@/tool/truncate"
+import { InstanceState } from "@/effect/instance-state"
 
 const node = CrossSpawnSpawner.defaultLayer
+const configLayer = TestConfig.layer({
+  directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
+})
+
+const registryLayer = ToolRegistry.layer.pipe(
+  Layer.provide(configLayer),
+  Layer.provide(Plugin.defaultLayer),
+  Layer.provide(Question.defaultLayer),
+  Layer.provide(Todo.defaultLayer),
+  Layer.provide(Skill.defaultLayer),
+  Layer.provide(Agent.defaultLayer),
+  Layer.provide(Session.defaultLayer),
+  Layer.provide(Provider.defaultLayer),
+  Layer.provide(LSP.defaultLayer),
+  Layer.provide(Instruction.defaultLayer),
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(Bus.layer),
+  Layer.provide(FetchHttpClient.layer),
+  Layer.provide(Format.defaultLayer),
+  Layer.provide(node),
+  Layer.provide(Ripgrep.defaultLayer),
+  Layer.provide(Truncate.defaultLayer),
+)
 
-const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
+const it = testEffect(Layer.mergeAll(registryLayer, node))
 
 afterEach(async () => {
   await disposeAllInstances()

+ 2 - 1
packages/opencode/test/tool/truncation.test.ts

@@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem"
 import path from "path"
 import { testEffect } from "../lib/effect"
 import { writeFileStringScoped } from "../lib/filesystem"
+import { TestConfig } from "../fixture/config"
 
 const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
 const ROOT = path.resolve(import.meta.dir, "..", "..")
@@ -19,7 +20,7 @@ const configuredLayer = (cfg: Config.Info) =>
   Layer.mergeAll(
     Truncate.defaultLayer,
     NodeFileSystem.layer,
-    Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
+    TestConfig.layer({ get: () => Effect.succeed(cfg) }),
   )
 const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))