Explorar el Código

Merge remote-tracking branch 'origin/dev' into deferred-tools

Aiden Cline hace 1 mes
padre
commit
0629920fc5
Se han modificado 40 ficheros con 361 adiciones y 380 borrados
  1. 11 12
      packages/core/src/effect/app-node-builder.ts
  2. 1 0
      packages/core/src/effect/dfdf
  3. 73 56
      packages/core/src/effect/layer-node.ts
  4. 6 7
      packages/core/src/location-services.ts
  5. 2 0
      packages/core/src/tool-output-store.ts
  6. 7 11
      packages/core/test/application-tools.test.ts
  7. 14 18
      packages/core/test/catalog.test.ts
  8. 4 2
      packages/core/test/config/agent.test.ts
  9. 4 2
      packages/core/test/config/command.test.ts
  10. 15 12
      packages/core/test/config/config.test.ts
  11. 2 1
      packages/core/test/credential.test.ts
  12. 12 5
      packages/core/test/effect/layer-node/layer-node-types.test.ts
  13. 35 11
      packages/core/test/effect/layer-node/layer-node.test.ts
  14. 25 23
      packages/core/test/effect/layer-node/node-build.test.ts
  15. 4 3
      packages/core/test/filesystem/filesystem.test.ts
  16. 5 14
      packages/core/test/location-filesystem.test.ts
  17. 5 14
      packages/core/test/location-mutation.test.ts
  18. 7 6
      packages/core/test/models.test.ts
  19. 6 11
      packages/core/test/plugin/models-dev.test.ts
  20. 3 15
      packages/core/test/plugin/variant.test.ts
  21. 4 10
      packages/core/test/project-copy.test.ts
  22. 6 21
      packages/core/test/reference.test.ts
  23. 5 12
      packages/core/test/repository-cache.test.ts
  24. 1 8
      packages/core/test/session-projector.test.ts
  25. 3 2
      packages/core/test/session-tool-progress.test.ts
  26. 8 7
      packages/core/test/skill-discovery.test.ts
  27. 1 3
      packages/core/test/skill.test.ts
  28. 8 10
      packages/core/test/system-context/builtins.test.ts
  29. 13 11
      packages/core/test/tool-output-store.test.ts
  30. 6 1
      packages/core/test/tool-question.test.ts
  31. 4 3
      packages/core/test/tool-read-filesystem.test.ts
  32. 7 2
      packages/core/test/tool-skill.test.ts
  33. 4 3
      packages/core/test/util/effect-flock.test.ts
  34. 8 8
      packages/opencode/src/cli/cmd/tui.ts
  35. 20 4
      packages/opencode/src/cli/network.ts
  36. 6 16
      packages/opencode/test/session/processor-effect.test.ts
  37. 5 10
      packages/opencode/test/session/snapshot-tool-race.test.ts
  38. 5 8
      packages/opencode/test/share/share-next.test.ts
  39. 5 15
      packages/opencode/test/tool/registry.test.ts
  40. 1 3
      packages/server/src/routes.ts

+ 11 - 12
packages/core/src/effect/app-node-builder.ts

@@ -1,24 +1,23 @@
-import { Layer } from "effect"
 import { buildLocationServiceMap } from "../location-services"
 import { LocationServiceMap } from "../location-service-map"
 import { LayerNode } from "./layer-node"
 import { makeGlobalNode } from "./app-node"
 
-export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
-  const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
+export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
+  let allReplacements = replacements
 
-  if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
-    // If the location service map is not needed, we shouldn't pull it
-    // in. Compile the graph normally
-    return LayerNode.compile(root, replacementMap)
+  // Only build the location service map if it's actually needed
+  if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
+    const locationMap = buildLocationServiceMap(replacements)
+    const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
+    allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
   }
 
-  const locationMap = buildLocationServiceMap(replacementMap)
-  const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
-
-  const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
+  return LayerNode.compile(root, allReplacements)
+}
 
-  return LayerNode.compile(app, replacementMap)
+function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
+  return replacements.some(([source]) => source.name === node.name)
 }
 
 export * as AppNodeBuilder from "./app-node-builder"

+ 1 - 0
packages/core/src/effect/dfdf

@@ -0,0 +1 @@
+File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/

+ 73 - 56
packages/core/src/effect/layer-node.ts

@@ -111,20 +111,52 @@ export function group<const Items extends readonly AnyNode[]>(
   return { kind: "group", name: "group", dependencies }
 }
 
-export type Replacement = {
-  readonly source: Layer.Any
-  readonly replacement: Layer.Any
-}
+export type Replacement = readonly [source: AnyNode, replacement: AnyNode | Layer.Any]
+export type Replacements = readonly Replacement[]
 
 type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
   ? unknown
   : { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
 
-export function replace<A, E, R, E2>(
-  source: Layer.Layer<A, E, R>,
-  replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
-): Replacement {
-  return { source, replacement }
+type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
+  ? Replacement extends Node<NoInfer<A>, infer E2, T>
+    ? CheckReplacementErrors<E, NoInfer<E2>>
+    : Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
+      ? CheckReplacementErrors<E, NoInfer<E2>>
+      : { readonly "Invalid replacement": Replacement }
+  : { readonly "Invalid replacement": Item }
+
+type CheckReplacements<Items extends Replacements> = {
+  readonly [K in keyof Items]: CheckReplacement<Items[K]>
+}
+
+type ValidReplacements<Items extends Replacements> = Items & CheckReplacements<Items>
+
+function replacementNode(source: AnyNode, replacement: AnyNode | Layer.Any) {
+  const replacementNode = isNode(replacement)
+    ? replacement
+    : make({
+        ...nodeMakeIdentity(source),
+        layer: replacement as Layer.Layer<unknown, unknown>,
+        deps: [],
+        tag: source.tag,
+      })
+  if (source.name !== replacementNode.name) {
+    throw new Error(`Cannot replace ${source.name} with ${replacementNode.name}`)
+  }
+  if (source.tag !== replacementNode.tag) {
+    throw new Error(`Cannot replace ${source.name} across tags`)
+  }
+  return replacementNode
+}
+
+function nodeMakeIdentity(node: AnyNode): NodeIdentity {
+  if (node.service !== undefined) return { service: node.service }
+  return { name: node.name }
+}
+
+function isNode(input: Layer.Any | AnyNode): input is AnyNode {
+  return "kind" in input && "dependencies" in input
 }
 
 // Tree -----------------------------------------------------------------------
@@ -176,32 +208,38 @@ function walk<Result>(
   return recur(root)
 }
 
-export function hoist<A, E, T extends Tag>(
+export function hoist<A, E, T extends Tag, const Items extends Replacements = readonly []>(
   root: Node<A, E, any>,
   tag: T,
+  replacements?: ValidReplacements<Items>,
 ): {
   readonly node: Node<A, E>
   readonly hoisted: Node<unknown, E>
 } {
   const hoisted = new Map<string, AnyNode>()
+  const replacementMap = replacementMapFrom(replacements)
 
-  const node = walk<AnyNode>(root, (node, context) => {
-    if (node.kind === "group") {
-      return { ...node, dependencies: node.dependencies.map(context.visit) }
-    }
-    if (node.tag === tag) {
-      const existing = hoisted.get(node.name)
-      if (existing && existing !== node) {
-        throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
+  const node = walk<AnyNode>(
+    root,
+    (node, context) => {
+      if (node.kind === "group") {
+        return { ...node, dependencies: node.dependencies.map(context.visit) }
       }
-      hoisted.set(node.name, node)
-      return group([])
-    }
-    if (node.kind === "unbound") {
-      return node
-    }
-    return { ...node, dependencies: node.dependencies.map(context.visit) }
-  })
+      if (node.tag === tag) {
+        const existing = hoisted.get(node.name)
+        if (existing && existing !== node) {
+          throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
+        }
+        hoisted.set(node.name, node)
+        return group([])
+      }
+      if (node.kind === "unbound") {
+        return node
+      }
+      return { ...node, dependencies: node.dependencies.map(context.visit) }
+    },
+    { resolve: (node) => replacementMap.get(node.name) ?? node },
+  )
 
   return {
     node: node as Node<A, E>,
@@ -209,10 +247,11 @@ export function hoist<A, E, T extends Tag>(
   }
 }
 
-export function compile<A, E>(
+export function compile<A, E, const Items extends Replacements = readonly []>(
   root: Node<A, E, any>,
-  replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
+  replacements?: ValidReplacements<Items>,
 ): Layer.Layer<A, E> {
+  const replacementMap = replacementMapFrom(replacements)
   const cache = new Map<AnyNode, RuntimeLayer>()
   const compileNode = (node: AnyNode) =>
     walk<RuntimeLayer>(
@@ -220,18 +259,22 @@ export function compile<A, E>(
       (node, context) => {
         if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
         const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
-        const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
+        const implementation = node.implementation! as RuntimeLayer
         return dependencies.length === 0
           ? implementation
           : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
       },
-      { cache },
+      { cache, resolve: (node) => replacementMap.get(node.name) ?? node },
     )
   const layers = flatten(root).map((node) => compileNode(node))
   const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
   return layer as Layer.Layer<A, E>
 }
 
+function replacementMapFrom(replacements?: Replacements) {
+  return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
+}
+
 export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
   if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
   return walk<boolean>(root, (node, context) => {
@@ -240,32 +283,6 @@ export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode):
   })
 }
 
-export function bind<A, E, T extends Tag | undefined>(
-  root: Node<A, E, T>,
-  source: AnyNode,
-  replacement: AnyNode,
-): Node<A, E, T> {
-  if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
-  if (source.name !== replacement.name) {
-    throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
-  }
-  if (source.tag !== replacement.tag) {
-    throw new Error(`Cannot bind ${source.name} across tags`)
-  }
-  return walk<AnyNode>(
-    root,
-    (target, context) => {
-      if (target.kind === "unbound") return target
-      const dependencies: AnyNode[] = []
-      const clone = { ...target, dependencies }
-      context.cache.set(target, clone)
-      dependencies.push(...target.dependencies.map(context.visit))
-      return clone
-    },
-    { detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
-  ) as Node<A, E, T>
-}
-
 function flatten(node: AnyNode): readonly AnyNode[] {
   return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
 }

+ 6 - 7
packages/core/src/location-services.ts

@@ -82,17 +82,16 @@ export type LocationServices = LayerNode.Output<typeof locationServices>
 export type LocationError = LayerNode.Error<typeof locationServices>
 
 export function buildLocationServiceMap(
-  replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
+  replacements: LayerNode.Replacements = [],
 ): Layer.Layer<LocationServiceMap.Service> {
   return Layer.effect(
     LocationServiceMap.Service,
     LayerMap.make(
       (ref: Location.Ref) => {
-        const location = LayerNode.hoist(
-          LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
-          Node.tags.values.global,
-        )
-        return LayerNode.compile(location.node, replacements).pipe(
+        const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
+        const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
+
+        return LayerNode.compile(location.node).pipe(
           Layer.fresh,
           Layer.tap(() =>
             Effect.logInfo("booting location services", {
@@ -100,7 +99,7 @@ export function buildLocationServiceMap(
               workspaceID: ref.workspaceID,
             }),
           ),
-          Layer.provide(LayerNode.compile(location.hoisted, replacements)),
+          Layer.provide(LayerNode.compile(location.hoisted)),
         )
       },
       { idleTimeToLive: "60 minutes" },

+ 2 - 0
packages/core/src/tool-output-store.ts

@@ -196,6 +196,8 @@ export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer
 
 export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] })
 
+export const nodeWithoutConfig = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node] })
+
 /** Runs retention scanning once globally rather than once per active Location. */
 export const cleanupLayer = Layer.effectDiscard(
   Effect.gen(function* () {

+ 7 - 11
packages/core/test/application-tools.test.ts

@@ -1,7 +1,8 @@
 import { describe, expect } from "bun:test"
 import { Tool } from "@opencode-ai/core/tool/tool"
 import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
-import { PermissionV2 } from "@opencode-ai/core/permission"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { AgentV2 } from "@opencode-ai/core/agent"
@@ -9,19 +10,14 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { Tools } from "@opencode-ai/core/tool/tools"
-import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
+import { Deferred, Effect, Exit, Fiber, Schema, Scope } from "effect"
 import { testEffect } from "./lib/effect"
 
-const permission = Layer.mock(PermissionV2.Service, {
-  assert: () => Effect.void,
-})
-const applications = ApplicationTools.layer
-const registry = ToolRegistry.layer.pipe(
-  Layer.provide(permission),
-  Layer.provide(applications),
-  Layer.provide(ToolOutputStore.defaultLayer),
+const it = testEffect(
+  AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node, ToolRegistry.toolsNode]), [
+    [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+  ]),
 )
-const it = testEffect(Layer.mergeAll(applications, registry))
 
 const sessionID = SessionV2.ID.make("ses_application_tool")
 const agent = AgentV2.ID.make("build")

+ 14 - 18
packages/core/test/catalog.test.ts

@@ -3,6 +3,8 @@ import { Effect, Fiber, Layer, Stream } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Integration } from "@opencode-ai/core/integration"
 import { Credential } from "@opencode-ai/core/credential"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Location } from "@opencode-ai/core/location"
 import { ModelV2 } from "@opencode-ai/core/model"
@@ -21,13 +23,11 @@ const locationLayer = Layer.succeed(
   Location.Service,
   Location.Service.of(location({ directory: AbsolutePath.make("test") })),
 )
-const it = testEffect(
-  Catalog.locationLayer.pipe(
-    Layer.provideMerge(EventV2.defaultLayer),
-    Layer.provideMerge(locationLayer),
-    Layer.provideMerge(Credential.defaultLayer),
-  ),
+const catalogLayer = AppNodeBuilder.build(
+  LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
+  [[Location.node, locationLayer]],
 )
+const it = testEffect(catalogLayer)
 
 describe("CatalogV2", () => {
   it.effect("publishes an updated event after catalog changes", () =>
@@ -47,11 +47,8 @@ describe("CatalogV2", () => {
 
   it.effect("derives availability from active credentials without changing provider state", () => {
     const integrationID = Integration.ID.make("test")
-    const layer = Catalog.locationLayer.pipe(
-      Layer.fresh,
-      Layer.provideMerge(EventV2.defaultLayer),
-      Layer.provideMerge(locationLayer),
-      Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
+    const localCatalogLayer = Layer.fresh(
+      AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
     )
 
     return Effect.gen(function* () {
@@ -73,17 +70,16 @@ describe("CatalogV2", () => {
       })
       expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
       expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
-    }).pipe(Effect.provide(layer))
+    }).pipe(Effect.provide(localCatalogLayer))
   })
 
   it.effect("derives availability from a provider's integration", () => {
     const integrationID = Integration.ID.make("gateway")
     const providerID = ProviderV2.ID.make("remote")
-    const layer = Catalog.locationLayer.pipe(
-      Layer.fresh,
-      Layer.provideMerge(EventV2.defaultLayer),
-      Layer.provideMerge(locationLayer),
-      Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
+    const localCatalogLayer = Layer.fresh(
+      AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
+        [Location.node, locationLayer],
+      ]),
     )
 
     return Effect.gen(function* () {
@@ -102,7 +98,7 @@ describe("CatalogV2", () => {
       })
 
       expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
-    }).pipe(Effect.provide(layer))
+    }).pipe(Effect.provide(localCatalogLayer))
   })
 
   it.effect("projects environment connections without a catalog plugin", () =>

+ 4 - 2
packages/core/test/config/agent.test.ts

@@ -1,10 +1,12 @@
 import { describe, expect } from "bun:test"
 import fs from "fs/promises"
 import path from "path"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -12,7 +14,7 @@ import { tmpdir } from "../fixture/tmpdir"
 import { testEffect } from "../lib/effect"
 import { agentHost, host } from "../plugin/host"
 
-const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
+const it = testEffect(AppNodeBuilder.build(LayerNode.group([AgentV2.node, FSUtil.node])))
 const decode = Schema.decodeUnknownSync(Config.Info)
 
 describe("ConfigAgentPlugin.Plugin", () => {

+ 4 - 2
packages/core/test/config/command.test.ts

@@ -1,10 +1,12 @@
 import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
 import { CommandV2 } from "@opencode-ai/core/command"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { ModelV2 } from "@opencode-ai/core/model"
 import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -13,7 +15,7 @@ import { tmpdir } from "../fixture/tmpdir"
 import { testEffect } from "../lib/effect"
 import { host } from "../plugin/host"
 
-const it = testEffect(Layer.mergeAll(CommandV2.locationLayer, FSUtil.defaultLayer))
+const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node])))
 const decode = Schema.decodeUnknownSync(Config.Info)
 
 describe("ConfigCommandPlugin.Plugin", () => {

+ 15 - 12
packages/core/test/config/config.test.ts

@@ -5,6 +5,9 @@ import { Effect, Layer, Schema } from "effect"
 import { FastCheck } from "effect/testing"
 import { Config } from "@opencode-ai/core/config"
 import { ConfigProvider } from "@opencode-ai/core/config/provider"
+import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
 import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -25,21 +28,21 @@ function testLayer(
   projectDirectory = directory,
   vcs?: Project.Vcs,
 ) {
-  return Config.locationLayer.pipe(
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(Global.layerWith({ config: globalDirectory })),
-    Layer.provide(
-      Layer.succeed(
-        Location.Service,
-        Location.Service.of(
-          location(
-            { directory: AbsolutePath.make(directory) },
-            { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
-          ),
-        ),
+  const configNode = makeLocationNode({
+    service: Config.Service,
+    layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))),
+    deps: [FSUtil.node, Location.node, Policy.node],
+  })
+  const locationLayer = Layer.succeed(
+    Location.Service,
+    Location.Service.of(
+      location(
+        { directory: AbsolutePath.make(directory) },
+        { projectDirectory: AbsolutePath.make(projectDirectory), vcs },
       ),
     ),
   )
+  return AppNodeBuilder.build(LayerNode.group([configNode, Policy.node]), [[Location.node, locationLayer]])
 }
 
 const provider = {

+ 2 - 1
packages/core/test/credential.test.ts

@@ -1,10 +1,11 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { Credential } from "@opencode-ai/core/credential"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Integration } from "@opencode-ai/core/integration"
 import { testEffect } from "./lib/effect"
 
-const it = testEffect(Credential.defaultLayer)
+const it = testEffect(LayerNode.compile(Credential.node))
 
 describe("Credential", () => {
   it.effect("stores, updates, lists, and removes credentials", () =>

+ 12 - 5
packages/core/test/effect/layer-node/layer-node-types.test.ts

@@ -56,16 +56,23 @@ const checkError: Layer.Layer<B, LayerError, never> = closedWithError
 void checkClosed
 void checkError
 
-LayerNode.replace(aLayer, Layer.succeed(A, A.of({})))
+LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
+LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
 
 // @ts-expect-error Replacement must provide A
-LayerNode.replace(aLayer, Layer.succeed(B, B.of({})))
+LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
+
+// @ts-expect-error Node replacement must provide A
+const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
+void invalidNodeReplacement
 
 // @ts-expect-error Replacement cannot introduce a new error
-LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError())))
+LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
 
-// @ts-expect-error Replacement must be closed
-LayerNode.replace(bLayer, bLayer)
+// @ts-expect-error Node replacement cannot introduce a new error
+const invalidNodeErrorReplacement = () =>
+  LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
+void invalidNodeErrorReplacement
 
 class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
 class TagB extends Context.Service<TagB, {}>()("test/TagB") {}

+ 35 - 11
packages/core/test/effect/layer-node/layer-node.test.ts

@@ -13,7 +13,7 @@ class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }
 const tags = LayerNode.tags({ app: [] })
 const make = tags.make("app")
 const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
-  LayerNode.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer<A, E>
+  LayerNode.compile(root, replacements) as Layer.Layer<A, E>
 const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
 const greetingLayer = Layer.effect(
   Greeting,
@@ -63,21 +63,20 @@ describe("layer node", () => {
     expect(await Effect.runPromise(program)).toEqual(["first", "second"])
   })
 
-  test("requires unbound nodes to be bound before compilation", async () => {
+  test("requires unbound nodes to be replaced before compilation", async () => {
     const unbound = LayerNode.unbound(Value, tags.values.app)
     const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
     const tree = LayerNode.group([greeting])
     expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
-    const bound = LayerNode.bind(tree, unbound, value)
-    const layer = LayerNode.compile(bound) as Layer.Layer<Greeting>
+    const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
     const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
     expect(await Effect.runPromise(program)).toBe("hello production")
   })
 
-  test("replaces a layer by identity", async () => {
+  test("replaces a node with a closed layer", async () => {
     const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
     const program = Effect.map(Greeting, (item) => item.value).pipe(
-      Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])),
+      Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
     )
     expect(await Effect.runPromise(program)).toBe("hello simulation")
   })
@@ -94,7 +93,7 @@ describe("layer node", () => {
     const left = make({ service: Left, layer: leftLayer, deps: [value] })
     const right = make({ service: Right, layer: rightLayer, deps: [value] })
     const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
-    const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)])
+    const layer = build(LayerNode.group([left, right]), [[value, replacement]])
     const program = Effect.gen(function* () {
       return [(yield* Left).value, (yield* Right).value]
     }).pipe(Effect.provide(layer))
@@ -103,22 +102,47 @@ describe("layer node", () => {
 
   test("does not acquire an unused replacement", async () => {
     let acquisitions = 0
-    const other = Layer.succeed(Value, Value.of({ value: "other" }))
+    const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
     const replacement = Layer.effect(
-      Value,
+      Left,
       Effect.sync(() => {
         acquisitions++
-        return Value.of({ value: "replacement" })
+        return Left.of({ value: "replacement" })
       }),
     )
     await Effect.runPromise(
       Effect.map(Greeting, (item) => item.value).pipe(
-        Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])),
+        Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
       ),
     )
     expect(acquisitions).toBe(0)
   })
 
+  test("replaces a node without acquiring its dependencies", async () => {
+    let acquisitions = 0
+    const dependencyLayer = Layer.effect(
+      Value,
+      Effect.sync(() => {
+        acquisitions++
+        return Value.of({ value: "dependency" })
+      }),
+    )
+    const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
+    const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
+    const replacement = make({
+      service: Greeting,
+      layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
+      deps: [],
+    })
+
+    const program = Effect.map(Greeting, (item) => item.value).pipe(
+      Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
+    )
+
+    expect(await Effect.runPromise(program)).toBe("replacement")
+    expect(acquisitions).toBe(0)
+  })
+
   test("hoists and compiles tagged graphs", async () => {
     const tags = LayerNode.tags({ location: ["global"], global: [] })
     const global = tags.make("global")

+ 25 - 23
packages/core/test/effect/layer-node/node-build.test.ts

@@ -1,8 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import { Context, Effect, Layer, LayerMap, Option } from "effect"
-import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Node } from "@opencode-ai/core/effect/app-node"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Location } from "@opencode-ai/core/location"
 import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
 import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
@@ -31,7 +31,7 @@ describe("node build", () => {
     expect(await Effect.runPromise(program)).toBe("plain")
   })
 
-  test("detects cycles through a bound location service map", () => {
+  test("detects cycles through a replaced location service map", async () => {
     const a = Node.makeGlobalNode({
       service: CycleA,
       layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
@@ -45,26 +45,28 @@ describe("node build", () => {
       ),
       deps: [a],
     })
-    const mapEffect = Effect.gen(function* () {
-      const service = yield* CycleB
-      return yield* LayerMap.make(
-        (ref: Location.Ref) =>
-          Layer.succeed(
-            Location.Service,
-            Location.Service.of({
-              directory: ref.directory,
-              workspaceID: ref.workspaceID,
-              project: { id: Project.ID.global, directory: service.directory },
-            }),
-          ),
-        { idleTimeToLive: "1 minute" },
-      )
-    }) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>
-    const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect)
+    const mapLayer = Layer.effect(
+      LocationServiceMap.Service,
+      Effect.gen(function* () {
+        const service = yield* CycleB
+        return yield* LayerMap.make(
+          (ref: Location.Ref) =>
+            Layer.succeed(
+              Location.Service,
+              Location.Service.of({
+                directory: ref.directory,
+                workspaceID: ref.workspaceID,
+                project: { id: Project.ID.global, directory: service.directory },
+              }),
+            ),
+          { idleTimeToLive: "1 minute" },
+        )
+      }) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
+    )
     const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
-    const graph = LayerNode.bind(LayerNode.group([a]), LocationServiceMap.node, map)
-
-    expect(() => AppNodeBuilder.build(graph)).toThrow("Cycle detected in layer tree")
+    expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
+      "Cycle detected in layer tree",
+    )
   })
 
   test("shares top-level project with location services", async () => {
@@ -81,10 +83,10 @@ describe("node build", () => {
         })
       }),
     )
+    const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
     const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
-      LayerNode.replace(Project.layer, projectLayer),
+      [Project.node, projectLayer],
     ])
-    const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
     const program = Effect.gen(function* () {
       yield* Project.Service
       const locations = yield* LocationServiceMap.Service

+ 4 - 3
packages/core/test/filesystem/filesystem.test.ts

@@ -1,11 +1,12 @@
 import { describe, test, expect } from "bun:test"
-import { Effect, Layer, FileSystem } from "effect"
-import { NodeFileSystem } from "@effect/platform-node"
+import { Effect, FileSystem } from "effect"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { testEffect } from "../lib/effect"
 import path from "path"
 
-const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
+const live = LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem]))
 const { effect: it } = testEffect(live)
 
 describe("FSUtil", () => {

+ 5 - 14
packages/core/test/location-filesystem.test.ts

@@ -2,7 +2,6 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Layer } from "effect"
-import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FileSystem } from "@opencode-ai/core/filesystem"
 import { Location } from "@opencode-ai/core/location"
@@ -13,20 +12,12 @@ import { it } from "./lib/effect"
 
 const provide = (directory: string) =>
   Effect.provide(
-    LayerNode.compile(
-      LayerNode.bind(
-        FileSystem.node,
+    LayerNode.compile(FileSystem.node, [
+      [
         Location.node,
-        makeLocationNode({
-          service: Location.Service,
-          layer: Layer.succeed(
-            Location.Service,
-            Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
-          ),
-          deps: [],
-        }),
-      ),
-    ),
+        Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
+      ],
+    ]),
   )
 
 const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>

+ 5 - 14
packages/core/test/location-mutation.test.ts

@@ -2,7 +2,6 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect, test } from "bun:test"
 import { Effect, Layer, Schema } from "effect"
-import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Location } from "@opencode-ai/core/location"
 import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -13,20 +12,12 @@ import { it } from "./lib/effect"
 
 function provide(directory: string) {
   return Effect.provide(
-    LayerNode.compile(
-      LayerNode.bind(
-        LocationMutation.node,
+    LayerNode.compile(LocationMutation.node, [
+      [
         Location.node,
-        makeLocationNode({
-          service: Location.Service,
-          layer: Layer.succeed(
-            Location.Service,
-            Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
-          ),
-          deps: [],
-        }),
-      ),
-    ),
+        Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
+      ],
+    ]),
   )
 }
 

+ 7 - 6
packages/core/test/models.test.ts

@@ -1,11 +1,12 @@
 import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
 import { Effect, Layer, Ref } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
-import { FSUtil } from "@opencode-ai/core/fs-util"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Global } from "@opencode-ai/core/global"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
-import { EventV2 } from "@opencode-ai/core/event"
 import { it } from "./lib/effect"
 import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
 import path from "path"
@@ -90,10 +91,10 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
   // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
   // and Effect.provide uses a process-global MemoMap by default — without fresh,
   // every test would reuse the cachedInvalidateWithTTL state from the first run.
-  Layer.fresh(ModelsDev.layer).pipe(
-    Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(EventV2.defaultLayer),
+  Layer.fresh(
+    AppNodeBuilder.build(ModelsDev.node, [
+      [LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
+    ]),
   )
 
 const writeCacheText = (text: string, mtimeMs?: number) =>

+ 6 - 11
packages/core/test/plugin/models-dev.test.ts

@@ -3,30 +3,25 @@ import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Integration } from "@opencode-ai/core/integration"
-import { Credential } from "@opencode-ai/core/credential"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Location } from "@opencode-ai/core/location"
 import { ModelsDev } from "@opencode-ai/core/models-dev"
 import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
-import { Policy } from "@opencode-ai/core/policy"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
 import { catalogHost, host, integrationHost } from "./host"
 
-const events = EventV2.defaultLayer
 const locationLayer = Layer.succeed(
   Location.Service,
   Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
 )
-const policy = Policy.layer.pipe(Layer.provide(locationLayer))
-const connections = Credential.defaultLayer.pipe(Layer.fresh)
-const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
-const catalog = Catalog.layer.pipe(
-  Layer.provide(Layer.mergeAll(events, locationLayer, policy, connections, integrations)),
-)
-const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer)
+const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, EventV2.node]), [
+  [Location.node, locationLayer],
+])
 const it = testEffect(layer)
 
 describe("ModelsDevPlugin", () => {
@@ -65,7 +60,7 @@ describe("ModelsDevPlugin", () => {
               connections: [],
             }),
           ])
-        }).pipe(Effect.provide(ModelsDev.defaultLayer)),
+        }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
       (previous) =>
         Effect.sync(() => {
           Flag.OPENCODE_MODELS_PATH = previous.path

+ 3 - 15
packages/core/test/plugin/variant.test.ts

@@ -1,11 +1,9 @@
 import { describe, expect } from "bun:test"
 import { Catalog } from "@opencode-ai/core/catalog"
-import { Credential } from "@opencode-ai/core/credential"
-import { EventV2 } from "@opencode-ai/core/event"
-import { Integration } from "@opencode-ai/core/integration"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Location } from "@opencode-ai/core/location"
 import { ModelV2 } from "@opencode-ai/core/model"
-import { Policy } from "@opencode-ai/core/policy"
 import { VariantPlugin } from "@opencode-ai/core/plugin/variant"
 import { ProviderV2 } from "@opencode-ai/core/provider"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -14,21 +12,11 @@ import { location } from "../fixture/location"
 import { testEffect } from "../lib/effect"
 import { catalogHost, host } from "./host"
 
-const events = EventV2.defaultLayer
 const locationLayer = Layer.succeed(
   Location.Service,
   Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
 )
-const connections = Credential.defaultLayer.pipe(Layer.fresh)
-const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
-const catalog = Catalog.layer.pipe(
-  Layer.provide(
-    Layer.mergeAll(events, locationLayer, Policy.layer.pipe(Layer.provide(locationLayer)), connections, integrations),
-  ),
-)
-const it = testEffect(
-  Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer),
-)
+const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]]))
 
 describe("VariantPlugin", () => {
   it.effect("adds GLM 5.2 variants after catalog sources", () =>

+ 4 - 10
packages/core/test/project-copy.test.ts

@@ -3,9 +3,10 @@ import { $ } from "bun"
 import fs from "fs/promises"
 import path from "path"
 import { eq } from "drizzle-orm"
-import { Effect, Fiber, Layer, Stream } from "effect"
+import { Effect, Fiber, Stream } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { AbsolutePath } from "@opencode-ai/core/schema"
-import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Git } from "@opencode-ai/core/git"
 import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
@@ -16,15 +17,8 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 
-const copyLayer = ProjectCopy.layer.pipe(
-  Layer.provide(Database.defaultLayer),
-  Layer.provide(ProjectDirectories.defaultLayer),
-  Layer.provide(EventV2.defaultLayer),
-  Layer.provide(FSUtil.defaultLayer),
-  Layer.provide(Git.defaultLayer),
-)
 const it = testEffect(
-  Layer.mergeAll(copyLayer, Database.defaultLayer, EventV2.defaultLayer, ProjectDirectories.defaultLayer),
+  AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])),
 )
 
 function abs(input: string) {

+ 6 - 21
packages/core/test/reference.test.ts

@@ -1,16 +1,18 @@
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Layer, Scope } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Global } from "@opencode-ai/core/global"
 import { Reference } from "@opencode-ai/core/reference"
 import { Repository } from "@opencode-ai/core/repository"
 import { RepositoryCache } from "@opencode-ai/core/repository-cache"
-import { EventV2 } from "@opencode-ai/core/event"
 import { it } from "./lib/effect"
 
 const cache = Layer.mock(RepositoryCache.Service, {
   ensure: () => Effect.die("unexpected Git materialization"),
 })
+const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
 
 describe("Reference", () => {
   it.effect("registers normalized sources for the owning scope", () =>
@@ -32,12 +34,7 @@ describe("Reference", () => {
 
       yield* Scope.close(scope, Exit.void)
       expect(yield* references.list()).toEqual([])
-    }).pipe(
-      Effect.provide(Reference.layer),
-      Effect.provide(cache),
-      Effect.provide(EventV2.defaultLayer),
-      Effect.provide(Global.defaultLayer),
-    ),
+    }).pipe(Effect.provide(referenceLayer)),
   )
 
   it.effect("derives Git paths without exposing cache operations", () =>
@@ -54,13 +51,7 @@ describe("Reference", () => {
           source,
         }),
       ])
-    }).pipe(
-      Effect.scoped,
-      Effect.provide(Reference.layer),
-      Effect.provide(cache),
-      Effect.provide(EventV2.defaultLayer),
-      Effect.provide(Global.defaultLayer),
-    ),
+    }).pipe(Effect.scoped, Effect.provide(referenceLayer)),
   )
 
   it.effect("preserves configured Git descriptions", () =>
@@ -82,12 +73,6 @@ describe("Reference", () => {
           source,
         }),
       ])
-    }).pipe(
-      Effect.scoped,
-      Effect.provide(Reference.layer),
-      Effect.provide(cache),
-      Effect.provide(EventV2.defaultLayer),
-      Effect.provide(Global.defaultLayer),
-    ),
+    }).pipe(Effect.scoped, Effect.provide(referenceLayer)),
   )
 })

+ 5 - 12
packages/core/test/repository-cache.test.ts

@@ -3,12 +3,11 @@ import fs from "fs/promises"
 import path from "path"
 import { pathToFileURL } from "url"
 import { Effect, Layer } from "effect"
-import { FSUtil } from "@opencode-ai/core/fs-util"
-import { Git } from "@opencode-ai/core/git"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Global } from "@opencode-ai/core/global"
 import { Repository } from "@opencode-ai/core/repository"
 import { RepositoryCache } from "@opencode-ai/core/repository-cache"
-import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { git, gitRemote } from "./fixture/git"
 import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
@@ -89,15 +88,9 @@ describe("RepositoryCache", () => {
 })
 
 function cacheLayer(root: string) {
-  const dependencies = Layer.mergeAll(
-    Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
-    FSUtil.defaultLayer,
-  )
-  return RepositoryCache.layer.pipe(
-    Layer.provide(EffectFlock.layer.pipe(Layer.provide(dependencies))),
-    Layer.provide(Git.defaultLayer),
-    Layer.provide(dependencies),
-  )
+  return AppNodeBuilder.build(RepositoryCache.node, [
+    [Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
+  ])
 }
 
 function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {

+ 1 - 8
packages/core/test/session-projector.test.ts

@@ -4,7 +4,6 @@ import { asc, eq } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
-import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { ModelV2 } from "@opencode-ai/core/model"
@@ -25,13 +24,7 @@ import { testEffect } from "./lib/effect"
 import { Snapshot } from "@opencode-ai/core/snapshot"
 
 const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
-const sessionsLayer = AppNodeBuilder.build(
-  LayerNode.bind(
-    SessionV2.node,
-    SessionExecution.node,
-    makeGlobalNode({ service: SessionExecution.Service, layer: SessionExecution.noopLayer, deps: [] }),
-  ),
-)
+const sessionsLayer = AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])
 const sessionID = SessionV2.ID.make("ses_projector_test")
 const created = DateTime.makeUnsafe(0)
 const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }

+ 3 - 2
packages/core/test/session-tool-progress.test.ts

@@ -1,7 +1,8 @@
 import { describe, expect } from "bun:test"
 import { asc, eq } from "drizzle-orm"
-import { DateTime, Effect, Layer, Schema } from "effect"
+import { DateTime, Effect, Schema } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { ModelV2 } from "@opencode-ai/core/model"
@@ -16,7 +17,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
 import { testEffect } from "./lib/effect"
 
-const it = testEffect(Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionProjector.defaultLayer))
+const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
 const timestamp = DateTime.makeUnsafe(1)
 const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
 

+ 8 - 7
packages/core/test/skill-discovery.test.ts

@@ -3,7 +3,9 @@ import path from "path"
 import { describe, expect, test } from "bun:test"
 import { Effect, Layer } from "effect"
 import { HttpClient, HttpClientResponse } from "effect/unstable/http"
-import { FSUtil } from "@opencode-ai/core/fs-util"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Global } from "@opencode-ai/core/global"
 import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
 import { tmpdir } from "./fixture/tmpdir"
@@ -27,15 +29,14 @@ async function pull(skills: unknown[], files: Record<string, string> = {}, cache
       ),
     ),
   )
-  const layer = SkillDiscovery.layer.pipe(
-    Layer.provide(http),
-    Layer.provide(FSUtil.defaultLayer),
-    Layer.provide(Global.layerWith({ cache: tmp.path })),
-  )
+  const skillDiscoveryLayer = AppNodeBuilder.build(SkillDiscovery.node, [
+    [LayerNodePlatform.httpClient, http],
+    [Global.node, Global.layerWith({ cache: tmp.path })],
+  ])
   const directories = await Effect.runPromise(
     Effect.gen(function* () {
       return yield* (yield* SkillDiscovery.Service).pull(base)
-    }).pipe(Effect.provide(layer)),
+    }).pipe(Effect.provide(skillDiscoveryLayer)),
   )
   return { tmp, requests, directories }
 }

+ 1 - 3
packages/core/test/skill.test.ts

@@ -24,9 +24,7 @@ const discovery = Layer.succeed(
   }),
 )
 const it = testEffect(
-  AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [
-    LayerNode.replace(SkillDiscovery.layer, discovery),
-  ]),
+  AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [[SkillDiscovery.node, discovery]]),
 )
 
 function write(directory: string, name: string, description: string) {

+ 8 - 10
packages/core/test/system-context/builtins.test.ts

@@ -1,7 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
 import * as TestClock from "effect/testing/TestClock"
-import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Location } from "@opencode-ai/core/location"
@@ -28,14 +27,12 @@ const locationLayer = Layer.succeed(
     ),
   ),
 )
-const locationNode = makeLocationNode({ service: Location.Service, layer: locationLayer, deps: [] })
-const builtInsNode = LayerNode.bind(
-  LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]),
-  Location.node,
-  locationNode,
-)
+const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
 const it = testEffect(
-  AppNodeBuilder.build(builtInsNode, [LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" }))]),
+  AppNodeBuilder.build(builtInsNode, [
+    [Location.node, locationLayer],
+    [Global.node, Global.layerWith({ config: "/global" })],
+  ]),
 )
 const instructionFS = Layer.effect(
   FSUtil.Service,
@@ -51,8 +48,9 @@ const instructionFS = Layer.effect(
 ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
 const itWithInstructions = testEffect(
   AppNodeBuilder.build(builtInsNode, [
-    LayerNode.replace(FSUtil.layer, instructionFS),
-    LayerNode.replace(Global.layer, Global.layerWith({ config: "/global" })),
+    [Location.node, locationLayer],
+    [FSUtil.node, instructionFS],
+    [Global.node, Global.layerWith({ config: "/global" })],
   ]),
 )
 

+ 13 - 11
packages/core/test/tool-output-store.test.ts

@@ -1,6 +1,8 @@
 import { describe, expect } from "bun:test"
 import path from "path"
 import { Cause, Effect, Exit, Fiber, Layer, Option } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Global } from "@opencode-ai/core/global"
 import { Config } from "@opencode-ai/core/config"
@@ -28,14 +30,14 @@ const withStore = <A, E, R>(
             }),
           )
         : Layer.empty
-      const store = ToolOutputStore.layer.pipe(
-        Layer.provide(FSUtil.defaultLayer),
-        Layer.provide(global),
-        Layer.provide(configured),
-      )
+
+      const store = AppNodeBuilder.build(LayerNode.group([ToolOutputStore.node, FSUtil.node]), [
+        [Global.node, global],
+        [Config.node, configured],
+      ])
       return Effect.gen(function* () {
         return yield* body({ root: tmp.path, store: yield* ToolOutputStore.Service, fs: yield* FSUtil.Service })
-      }).pipe(Effect.provide(Layer.mergeAll(store, FSUtil.defaultLayer)))
+      }).pipe(Effect.provide(store))
     },
     (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
   )
@@ -185,11 +187,11 @@ describe("ToolOutputStore", () => {
             writeFileString: () => Effect.never,
           })
         }),
-      ).pipe(Layer.provide(FSUtil.defaultLayer))
-      const store = ToolOutputStore.layer.pipe(
-        Layer.provide(blockedFilesystem),
-        Layer.provide(Global.layerWith({ data: root.path })),
-      )
+      ).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
+      const store = AppNodeBuilder.build(ToolOutputStore.nodeWithoutConfig, [
+        [Global.node, Global.layerWith({ data: root.path })],
+        [FSUtil.node, blockedFilesystem],
+      ])
       const exit = yield* Effect.gen(function* () {
         const service = yield* ToolOutputStore.Service
         const fiber = yield* service

+ 6 - 1
packages/core/test/tool-question.test.ts

@@ -1,10 +1,13 @@
 import { describe, expect } from "bun:test"
 import { Effect, Exit, Fiber, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { QuestionV2 } from "@opencode-ai/core/question"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { QuestionTool } from "@opencode-ai/core/tool/question"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { testEffect } from "./lib/effect"
 import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
 
@@ -28,7 +31,9 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
+const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
+  [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+])
 const question = Layer.succeed(
   QuestionV2.Service,
   QuestionV2.Service.of({

+ 4 - 3
packages/core/test/tool-read-filesystem.test.ts

@@ -1,12 +1,13 @@
 import { describe, expect } from "bun:test"
-import { NodeFileSystem } from "@effect/platform-node"
 import path from "path"
-import { Effect, FileSystem, Layer } from "effect"
+import { Effect, FileSystem } from "effect"
+import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
 import { testEffect } from "./lib/effect"
 
-const it = testEffect(Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer))
+const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
 const fixture = Effect.gen(function* () {
   const fs = yield* FSUtil.Service
   const files = yield* FileSystem.FileSystem

+ 7 - 2
packages/core/test/tool-skill.test.ts

@@ -2,6 +2,8 @@ import fs from "fs/promises"
 import path from "path"
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { FSUtil } from "@opencode-ai/core/fs-util"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -9,6 +11,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { SkillV2 } from "@opencode-ai/core/skill"
 import { SkillTool } from "@opencode-ai/core/tool/skill"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
+import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { tmpdir } from "./fixture/tmpdir"
 import { it } from "./lib/effect"
 import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -63,11 +66,13 @@ describe("SkillTool", () => {
               list: () => Effect.succeed(current),
             }),
           )
-          const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
+          const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
+            [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
+          ])
           const tool = SkillTool.layer.pipe(
             Layer.provide(registry),
             Layer.provide(permission),
-            Layer.provide(FSUtil.defaultLayer),
+            Layer.provide(LayerNode.compile(FSUtil.node)),
             Layer.provide(skills),
           )
           const layer = Layer.mergeAll(permission, skills, registry, tool)

+ 4 - 3
packages/core/test/util/effect-flock.test.ts

@@ -3,9 +3,10 @@ import { spawn } from "child_process"
 import fs from "fs/promises"
 import path from "path"
 import os from "os"
-import { Cause, Effect, Exit, Layer } from "effect"
+import { Cause, Effect, Exit } from "effect"
 import { testEffect } from "../lib/effect"
-import { FSUtil } from "@opencode-ai/core/fs-util"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { Global } from "@opencode-ai/core/global"
 import { Hash } from "@opencode-ai/core/util/hash"
@@ -109,7 +110,7 @@ const testGlobal = Global.layerWith({
   log: os.tmpdir(),
 })
 
-const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
+const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]])
 
 // ---------------------------------------------------------------------------
 // Tests

+ 8 - 8
packages/opencode/src/cli/cmd/tui.ts

@@ -6,11 +6,12 @@ import { fileURLToPath } from "url"
 import { UI } from "@/cli/ui"
 import { errorMessage } from "@opencode-ai/tui/util/error"
 import { withTimeout } from "@/util/timeout"
-import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
+import { withNetworkOptions, resolveNetworkOptionsNoConfig, hasArg } from "@/cli/network"
 import { Filesystem } from "@/util/filesystem"
 import type { GlobalEvent } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "@opencode-ai/tui/context/sdk"
 import { writeHeapSnapshot } from "v8"
+import { ServerAuth } from "@/server/auth"
 import { validateSession } from "../tui/validate-session"
 import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
 
@@ -211,19 +212,16 @@ export const TuiThreadCommand = cmd({
       const config = await TuiConfig.get()
 
       const network = resolveNetworkOptionsNoConfig(args)
-      const external =
-        process.argv.includes("--port") ||
-        process.argv.includes("--hostname") ||
-        process.argv.includes("--mdns") ||
-        network.mdns ||
-        network.port !== 0 ||
-        network.hostname !== "127.0.0.1"
+      const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true
+
+      const headers = external ? ServerAuth.headers() : undefined
 
       const transport = external
         ? {
             url: (await client.call("server", network)).url,
             fetch: undefined,
             events: undefined,
+            headers,
           }
         : {
             url: "http://opencode.internal",
@@ -237,6 +235,7 @@ export const TuiThreadCommand = cmd({
           sessionID: args.session,
           directory: cwd,
           fetch: transport.fetch,
+          headers,
         })
       } catch (error) {
         UI.error(errorMessage(error))
@@ -264,6 +263,7 @@ export const TuiThreadCommand = cmd({
             pluginHost: createLegacyTuiPluginHost(),
             directory: cwd,
             fetch: transport.fetch,
+            headers: transport.headers,
             events: transport.events,
             args: {
               continue: args.continue,

+ 20 - 4
packages/opencode/src/cli/network.ts

@@ -37,6 +37,22 @@ export type NetworkOptions = InferredOptionTypes<typeof options>
 export function withNetworkOptions<T>(yargs: Argv<T>) {
   return yargs.options(options)
 }
+
+export function hasArg(name: string) {
+  return networkArgs().some((arg) => arg === name || arg.startsWith(name + "="))
+}
+
+function hasBooleanArg(name: string) {
+  return networkArgs().some(
+    (arg) => arg === name || arg === name + "=true" || arg === name + "=false" || arg === "--no-" + name.slice(2),
+  )
+}
+
+function networkArgs() {
+  const separator = process.argv.indexOf("--")
+  return process.argv.slice(2, separator === -1 ? undefined : separator)
+}
+
 export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(function* (args: NetworkOptions) {
   const { Config } = yield* Effect.promise(() => import("@/config/config"))
   const config = yield* Config.Service.use((cfg) => cfg.getGlobal())
@@ -44,10 +60,10 @@ export const resolveNetworkOptions = Effect.fn("Cli.resolveNetworkOptions")(func
 })
 
 export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: ConfigV1.Info) {
-  const portExplicitlySet = process.argv.includes("--port")
-  const hostnameExplicitlySet = process.argv.includes("--hostname")
-  const mdnsExplicitlySet = process.argv.includes("--mdns")
-  const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
+  const portExplicitlySet = hasArg("--port")
+  const hostnameExplicitlySet = hasArg("--hostname")
+  const mdnsExplicitlySet = hasBooleanArg("--mdns")
+  const mdnsDomainExplicitlySet = hasArg("--mdns-domain")
   const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
   const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
   const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)

+ 6 - 16
packages/opencode/test/session/processor-effect.test.ts

@@ -176,12 +176,12 @@ const root = LayerNode.group([
   CrossSpawnSpawner.node,
 ])
 const replacements = [
-  LayerNode.replace(SessionSummary.layer, summary),
-  LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })),
-]
+  [SessionSummary.node, summary],
+  [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
+] as const
 const env = LayerNode.compile(
   LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]),
-  new Map(replacements.map((item) => [item.source, item.replacement])),
+  replacements,
 )
 
 const it = testEffect(env)
@@ -206,12 +206,7 @@ const providerErrorLLM = Layer.succeed(
       ),
   }),
 )
-const providerErrorEnv = LayerNode.compile(
-  root,
-  new Map(
-    [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)].map((item) => [item.source, item.replacement]),
-  ),
-)
+const providerErrorEnv = LayerNode.compile(root, [...replacements, [LLM.node, providerErrorLLM]])
 const itProviderError = testEffect(providerErrorEnv)
 
 const fragmentFailureLLM = Layer.succeed(
@@ -228,12 +223,7 @@ const fragmentFailureLLM = Layer.succeed(
       ),
   }),
 )
-const fragmentFailureEnv = LayerNode.compile(
-  root,
-  new Map(
-    [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [item.source, item.replacement]),
-  ),
-)
+const fragmentFailureEnv = LayerNode.compile(root, [...replacements, [LLM.node, fragmentFailureLLM]])
 const itFragmentFailure = testEffect(fragmentFailureEnv)
 
 const boot = Effect.fn("test.boot")(function* () {

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

@@ -87,16 +87,11 @@ const root = LayerNode.group([
   LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }),
 ])
 const it = testEffect(
-  LayerNode.compile(
-    root,
-    new Map(
-      [
-        LayerNode.replace(MCP.layer, mcp),
-        LayerNode.replace(LSP.layer, lsp),
-        LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })),
-      ].map((item) => [item.source, item.replacement]),
-    ),
-  ),
+  LayerNode.compile(root, [
+    [MCP.node, mcp],
+    [LSP.node, lsp],
+    [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })],
+  ]),
 )
 
 const providerCfg = (url: string) => ({

+ 5 - 8
packages/opencode/test/share/share-next.test.ts

@@ -1,6 +1,6 @@
 import { beforeEach, describe, expect } from "bun:test"
 import { Effect, Exit, Layer, Option } from "effect"
-import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -34,15 +34,12 @@ const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unkno
 const none = HttpClient.make(() => Effect.die("unexpected http call"))
 
 function requestLayer(client: HttpClient.HttpClient) {
-  const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))
-  return LayerNode.compile(
-    LayerNode.group([ShareNext.node, AccountRepo.node]),
-    new Map([[replacement.source, replacement.replacement]]),
-  )
+  const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const
+  return LayerNode.compile(LayerNode.group([ShareNext.node, AccountRepo.node]), [replacement])
 }
 
 function integrationLayer(client: HttpClient.HttpClient) {
-  const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))
+  const replacement = [httpClient, Layer.succeed(HttpClient.HttpClient, client)] as const
   return LayerNode.compile(
     LayerNode.group([
       ShareNext.node,
@@ -52,7 +49,7 @@ function integrationLayer(client: HttpClient.HttpClient) {
       AccountRepo.node,
       Database.node,
     ]),
-    new Map([[replacement.source, replacement.replacement]]),
+    [replacement],
   )
 }
 

+ 5 - 15
packages/opencode/test/tool/registry.test.ts

@@ -50,22 +50,12 @@ const brokenPluginLayer = Layer.succeed(
 
 const root = LayerNode.group([ToolRegistry.node, Agent.node])
 const replacements = [
-  LayerNode.replace(Config.layer, configLayer),
-  LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()),
-]
+  [Config.node, configLayer],
+  [RuntimeFlags.node, RuntimeFlags.layer()],
+] as const
 
-const it = testEffect(LayerNode.compile(root, new Map(replacements.map((item) => [item.source, item.replacement]))))
-const withBrokenPlugin = testEffect(
-  LayerNode.compile(
-    root,
-    new Map(
-      [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)].map((item) => [
-        item.source,
-        item.replacement,
-      ]),
-    ),
-  ),
-)
+const it = testEffect(LayerNode.compile(root, replacements))
+const withBrokenPlugin = testEffect(LayerNode.compile(root, [...replacements, [Plugin.node, brokenPluginLayer]]))
 
 afterEach(async () => {
   await disposeAllInstances()

+ 1 - 3
packages/server/src/routes.ts

@@ -49,9 +49,7 @@ export function createEmbeddedRoutes() {
 }
 
 function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
-  const serviceLayer = AppNodeBuilder.build(
-    LayerNode.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node),
-  )
+  const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]])
 
   return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
     Layer.provide(handlers),