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

feat(opencode): add typed application layer graph (#31531)

James Long 2 месяцев назад
Родитель
Сommit
07e5ea9367
68 измененных файлов с 759 добавлено и 0 удалено
  1. 3 0
      packages/core/src/cross-spawn-spawner.ts
  2. 3 0
      packages/core/src/database/database.ts
  3. 12 0
      packages/core/src/effect/layer-node-platform.ts
  4. 95 0
      packages/core/src/effect/layer-node.ts
  5. 2 0
      packages/core/src/event.ts
  6. 3 0
      packages/core/src/filesystem/ripgrep.ts
  7. 2 0
      packages/core/src/filesystem/search.ts
  8. 3 0
      packages/core/src/fs-util.ts
  9. 2 0
      packages/core/src/git.ts
  10. 2 0
      packages/core/src/global.ts
  11. 3 0
      packages/core/src/models-dev.ts
  12. 3 0
      packages/core/src/npm.ts
  13. 2 0
      packages/core/src/process.ts
  14. 2 0
      packages/core/src/project.ts
  15. 2 0
      packages/core/src/project/copy.ts
  16. 2 0
      packages/core/src/pty/ticket.ts
  17. 2 0
      packages/core/src/session/projector.ts
  18. 2 0
      packages/core/src/util/effect-flock.ts
  19. 4 0
      packages/opencode/src/account/account.ts
  20. 3 0
      packages/opencode/src/account/repo.ts
  21. 3 0
      packages/opencode/src/agent/agent.ts
  22. 3 0
      packages/opencode/src/auth/index.ts
  23. 3 0
      packages/opencode/src/background/job.ts
  24. 3 0
      packages/opencode/src/command/index.ts
  25. 4 0
      packages/opencode/src/config/config.ts
  26. 14 0
      packages/opencode/src/control-plane/workspace.ts
  27. 3 0
      packages/opencode/src/effect/runtime-flags.ts
  28. 3 0
      packages/opencode/src/env/index.ts
  29. 3 0
      packages/opencode/src/event-v2-bridge.ts
  30. 3 0
      packages/opencode/src/format/index.ts
  31. 3 0
      packages/opencode/src/git/index.ts
  32. 3 0
      packages/opencode/src/image/image.ts
  33. 4 0
      packages/opencode/src/installation/index.ts
  34. 4 0
      packages/opencode/src/lsp/lsp.ts
  35. 3 0
      packages/opencode/src/mcp/auth.ts
  36. 3 0
      packages/opencode/src/mcp/index.ts
  37. 3 0
      packages/opencode/src/permission/index.ts
  38. 3 0
      packages/opencode/src/plugin/index.ts
  39. 13 0
      packages/opencode/src/project/bootstrap.ts
  40. 4 0
      packages/opencode/src/project/instance-store.ts
  41. 12 0
      packages/opencode/src/project/project.ts
  42. 3 0
      packages/opencode/src/project/vcs.ts
  43. 3 0
      packages/opencode/src/provider/auth.ts
  44. 11 0
      packages/opencode/src/provider/provider.ts
  45. 3 0
      packages/opencode/src/question/index.ts
  46. 12 0
      packages/opencode/src/session/compaction.ts
  47. 4 0
      packages/opencode/src/session/instruction.ts
  48. 13 0
      packages/opencode/src/session/llm.ts
  49. 17 0
      packages/opencode/src/session/processor.ts
  50. 30 0
      packages/opencode/src/session/prompt.ts
  51. 10 0
      packages/opencode/src/session/revert.ts
  52. 3 0
      packages/opencode/src/session/run-state.ts
  53. 3 0
      packages/opencode/src/session/session.ts
  54. 3 0
      packages/opencode/src/session/status.ts
  55. 3 0
      packages/opencode/src/session/summary.ts
  56. 3 0
      packages/opencode/src/session/system.ts
  57. 3 0
      packages/opencode/src/session/todo.ts
  58. 3 0
      packages/opencode/src/share/session.ts
  59. 12 0
      packages/opencode/src/share/share-next.ts
  60. 4 0
      packages/opencode/src/skill/discovery.ts
  61. 10 0
      packages/opencode/src/skill/index.ts
  62. 3 0
      packages/opencode/src/snapshot/index.ts
  63. 3 0
      packages/opencode/src/storage/storage.ts
  64. 27 0
      packages/opencode/src/tool/registry.ts
  65. 3 0
      packages/opencode/src/tool/truncate.ts
  66. 12 0
      packages/opencode/src/worktree/index.ts
  67. 101 0
      packages/opencode/test/effect/app-graph-types.test.ts
  68. 208 0
      packages/opencode/test/effect/app-graph.test.ts

+ 3 - 0
packages/core/src/cross-spawn-spawner.ts

@@ -24,6 +24,8 @@ import {
 import * as NodeChildProcess from "node:child_process"
 import { PassThrough } from "node:stream"
 import launch from "cross-spawn"
+import { LayerNode } from "./effect/layer-node"
+import { filesystem, path } from "./effect/layer-node-platform"
 
 const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
 
@@ -501,5 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
 )
 
 export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
+export const node = LayerNode.make(layer, [filesystem, path])
 
 export * as CrossSpawnSpawner from "./cross-spawn-spawner"

+ 3 - 0
packages/core/src/database/database.ts

@@ -8,6 +8,7 @@ import { Flag } from "../flag/flag"
 import { isAbsolute, join } from "path"
 import { DatabaseMigration } from "./migration"
 import { InstallationChannel } from "../installation/version"
+import { LayerNode } from "../effect/layer-node"
 
 const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
 type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -58,3 +59,5 @@ export const defaultLayer = Layer.unwrap(
     return layerFromPath(path())
   }),
 ).pipe(Layer.provide(Global.defaultLayer))
+
+export const node = LayerNode.make(layerFromPath(path()), [])

+ 12 - 0
packages/core/src/effect/layer-node-platform.ts

@@ -0,0 +1,12 @@
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
+import { FetchHttpClient } from "effect/unstable/http"
+import { LayerNode } from "./layer-node"
+
+export const filesystem = LayerNode.make(NodeFileSystem.layer, [])
+export const path = LayerNode.make(NodePath.layer, [])
+export const httpClient = LayerNode.make(FetchHttpClient.layer, [])
+export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient])
+export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor])
+
+export * as LayerNodePlatform from "./layer-node-platform"

+ 95 - 0
packages/core/src/effect/layer-node.ts

@@ -0,0 +1,95 @@
+import { Layer } from "effect"
+
+type RuntimeLayer = Layer.Layer<never, unknown, unknown>
+type AnyNode = Node<unknown, unknown>
+type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
+type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
+type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
+type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
+type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
+  Missing<Layer.Services<Implementation>, Dependencies>,
+] extends [never]
+  ? unknown
+  : { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
+declare const $OutputType: unique symbol
+declare const $ErrorType: unique symbol
+
+export type Node<A, E = never> = {
+  readonly kind: "layer" | "group"
+  readonly implementation?: Layer.Any
+  readonly dependencies: readonly AnyNode[]
+  readonly [$OutputType]?: () => A
+  readonly [$ErrorType]?: () => E
+}
+
+export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
+  implementation: Implementation,
+  dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
+): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
+  return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
+}
+
+export function group<const Items extends NodeList>(
+  dependencies: Items,
+): Node<Output<Items[number]>, Error<Items[number]>> {
+  return { kind: "group", dependencies }
+}
+
+export type Replacement<A = unknown> = {
+  readonly source: Node<A, unknown>
+  readonly replacement: Node<A, unknown>
+}
+
+type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
+  ? unknown
+  : { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
+
+export function replace<A, E, E2>(
+  source: Node<A, E>,
+  replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
+): Replacement<A> {
+  return { source, replacement }
+}
+
+export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
+  const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
+  const cache = new Map<AnyNode, RuntimeLayer>()
+  const visiting = new Set<AnyNode>()
+  const stack: AnyNode[] = []
+  const ids = new Map<AnyNode, number>()
+
+  const visit = (input: AnyNode): RuntimeLayer => {
+    const node = replacements.get(input) ?? input
+    const cached = cache.get(node)
+    if (cached) return cached
+    if (visiting.has(node)) {
+      const start = stack.indexOf(node)
+      const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ")
+      throw new Error(`Cycle detected in app graph: ${cycle}`)
+    }
+    if (!ids.has(node)) ids.set(node, ids.size + 1)
+    visiting.add(node)
+    stack.push(node)
+    try {
+      const dependencies = node.dependencies.map(visit)
+      const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
+      const result =
+        node.kind === "group"
+          ? dependencies.length === 0
+            ? Layer.empty
+            : Layer.mergeAll(...nonEmpty)
+          : dependencies.length === 0
+            ? (node.implementation as RuntimeLayer)
+            : Layer.provide(node.implementation as RuntimeLayer, nonEmpty)
+      cache.set(node, result)
+      return result
+    } finally {
+      stack.pop()
+      visiting.delete(node)
+    }
+  }
+
+  return visit(node) as unknown as Layer.Layer<A, E, never>
+}
+
+export * as LayerNode from "./layer-node"

+ 2 - 0
packages/core/src/event.ts

@@ -7,6 +7,7 @@ import { EventSequenceTable, EventTable } from "./event/sql"
 import { Location } from "./location"
 import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
 import { Identifier } from "./util/identifier"
+import { LayerNode } from "./effect/layer-node"
 import { isDeepStrictEqual } from "node:util"
 
 export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
@@ -674,5 +675,6 @@ export const layerWith = (options?: LayerOptions) =>
   )
 
 export const layer = layerWith()
+export const node = LayerNode.make(layer, [Database.node])
 
 export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))

+ 3 - 0
packages/core/src/filesystem/ripgrep.ts

@@ -11,6 +11,8 @@ import { CrossSpawnSpawner } from "../cross-spawn-spawner"
 import { Global } from "../global"
 import { NonNegativeInt } from "../schema"
 import { which } from "../util/which"
+import { LayerNode } from "../effect/layer-node"
+import { httpClient } from "../effect/layer-node-platform"
 
 const VERSION = "15.1.0"
 const PLATFORM = {
@@ -480,5 +482,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(FSUtil.defaultLayer),
   Layer.provide(CrossSpawnSpawner.defaultLayer),
 )
+export const node = LayerNode.make(layer, [FSUtil.node, CrossSpawnSpawner.node, httpClient])
 
 export * as Ripgrep from "./ripgrep"

+ 2 - 0
packages/core/src/filesystem/search.ts

@@ -8,6 +8,7 @@ import { serviceUse } from "../effect/service-use"
 import { makeRuntime } from "../effect/runtime"
 import { Fff } from "#fff"
 import { Ripgrep } from "./ripgrep"
+import { LayerNode } from "../effect/layer-node"
 
 const root = path.join(Global.Path.cache, "fff")
 
@@ -539,6 +540,7 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
   Layer.provide(Ripgrep.defaultLayer),
   Layer.provide(FSUtil.defaultLayer),
 )
+export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node])
 
 const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 3 - 0
packages/core/src/fs-util.ts

@@ -7,6 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
 import type { PlatformError } from "effect/PlatformError"
 import { Glob } from "./util/glob"
 import { serviceUse } from "./effect/service-use"
+import { LayerNode } from "./effect/layer-node"
+import { filesystem } from "./effect/layer-node-platform"
 
 export namespace FSUtil {
   export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
@@ -194,6 +196,7 @@ export namespace FSUtil {
   )
 
   export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
+  export const node = LayerNode.make(layer, [filesystem])
 
   // Pure helpers that don't need Effect (path manipulation, sync operations)
   export function mimeType(p: string): string {

+ 2 - 0
packages/core/src/git.ts

@@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process"
 import { AbsolutePath } from "./schema"
 import { FSUtil } from "./fs-util"
 import { AppProcess } from "./process"
+import { LayerNode } from "./effect/layer-node"
 
 export interface Repo {
   /**
@@ -400,6 +401,7 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
+export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node])
 
 export interface Result {
   readonly exitCode: number

+ 2 - 0
packages/core/src/global.ts

@@ -5,6 +5,7 @@ import os from "os"
 import { Context, Effect, Layer } from "effect"
 import { Flock } from "./util/flock"
 import { Flag } from "./flag/flag"
+import { LayerNode } from "./effect/layer-node"
 
 const app = "opencode"
 const data = path.join(xdgData!, app)
@@ -76,6 +77,7 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer
+export const node = LayerNode.make(layer, [])
 
 export const layerWith = (input: Partial<Interface>) =>
   Layer.effect(

+ 3 - 0
packages/core/src/models-dev.ts

@@ -8,6 +8,8 @@ import { Hash } from "./util/hash"
 import { FSUtil } from "./fs-util"
 import { InstallationChannel, InstallationVersion } from "./installation/version"
 import { EventV2 } from "./event"
+import { LayerNode } from "./effect/layer-node"
+import { httpClient } from "./effect/layer-node-platform"
 
 export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
 export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -246,5 +248,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(FSUtil.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
 )
+export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient])
 
 export * as ModelsDev from "./models-dev"

+ 3 - 0
packages/core/src/npm.ts

@@ -7,6 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node"
 import { FSUtil } from "./fs-util"
 import { Global } from "./global"
 import { EffectFlock } from "./util/effect-flock"
+import { LayerNode } from "./effect/layer-node"
+import { filesystem } from "./effect/layer-node-platform"
 import { makeRuntime } from "./effect/runtime"
 import { NpmConfig } from "./npm-config"
 
@@ -250,6 +252,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Global.layer),
   Layer.provide(NodeFileSystem.layer),
 )
+export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node])
 
 const { runPromise } = makeRuntime(Service, defaultLayer)
 

+ 2 - 0
packages/core/src/process.ts

@@ -3,6 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
 import { ChildProcess } from "effect/unstable/process"
 import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
 import { CrossSpawnSpawner } from "./cross-spawn-spawner"
+import { LayerNode } from "./effect/layer-node"
 
 export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
   command: Schema.String,
@@ -230,5 +231,6 @@ export const layer = Layer.effect(
 )
 
 export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
+export const node = LayerNode.make(layer, [CrossSpawnSpawner.node])
 
 export * as AppProcess from "./process"

+ 2 - 0
packages/core/src/project.ts

@@ -8,6 +8,7 @@ import { AbsolutePath, withStatics } from "./schema"
 import { FSUtil } from "./fs-util"
 import { Database } from "./database/database"
 import { Git } from "./git"
+import { LayerNode } from "./effect/layer-node"
 import { Hash } from "./util/hash"
 import { ProjectDirectoryTable } from "./project/sql"
 
@@ -159,3 +160,4 @@ export const defaultLayer = layer.pipe(
   Layer.provide(FSUtil.defaultLayer),
   Layer.provide(Git.defaultLayer),
 )
+export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])

+ 2 - 0
packages/core/src/project/copy.ts

@@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util"
 import { Git } from "../git"
 import { Database } from "../database/database"
 import { EventV2 } from "../event"
+import { LayerNode } from "../effect/layer-node"
 import { Project } from "../project"
 import { ProjectDirectoryTable } from "./sql"
 import { makeStrategies } from "./copy-strategies"
@@ -275,3 +276,4 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Git.defaultLayer),
   Layer.provide(EventV2.defaultLayer),
 )
+export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])

+ 2 - 0
packages/core/src/pty/ticket.ts

@@ -4,6 +4,7 @@ import { WorkspaceV2 } from "../workspace"
 import { PositiveInt } from "../schema"
 import { PtyID } from "./schema"
 import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
+import { LayerNode } from "../effect/layer-node"
 
 const DEFAULT_TTL = Duration.seconds(60)
 const CAPACITY = 10_000
@@ -56,3 +57,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
 export const layer = Layer.effect(Service, make())
 
 export const defaultLayer = layer
+export const node = LayerNode.make(layer, [])

+ 2 - 0
packages/core/src/session/projector.ts

@@ -4,6 +4,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
 import { DateTime, Effect, Layer, Schema } from "effect"
 import { Database } from "../database/database"
 import { EventV2 } from "../event"
+import { LayerNode } from "../effect/layer-node"
 import { SessionEvent } from "./event"
 import { SessionV1 } from "../v1/session"
 import { WorkspaceTable } from "../control-plane/workspace.sql"
@@ -447,3 +448,4 @@ export const layer = Layer.effectDiscard(
 )
 
 export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))
+export const node = LayerNode.make(layer, [EventV2.node, Database.node])

+ 2 - 0
packages/core/src/util/effect-flock.ts

@@ -6,6 +6,7 @@ import type { FileSystem, Scope } from "effect"
 import type { PlatformError } from "effect/PlatformError"
 import { FSUtil } from "../fs-util"
 import { Global } from "../global"
+import { LayerNode } from "../effect/layer-node"
 import { Hash } from "./hash"
 
 export namespace EffectFlock {
@@ -280,4 +281,5 @@ export namespace EffectFlock {
   )
 
   export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer))
+  export const node = LayerNode.make(layer, [Global.node, FSUtil.node])
 }

+ 4 - 0
packages/opencode/src/account/account.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import {
@@ -456,4 +458,6 @@ export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient
 
 export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
 
+export const node = LayerNode.make(layer, [AccountRepo.node, httpClient])
+
 export * as Account from "./account"

+ 3 - 0
packages/opencode/src/account/repo.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { eq } from "drizzle-orm"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { Effect, Layer, Option, Schema, Context } from "effect"
@@ -167,4 +168,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
 
+export const node = LayerNode.make(layer, [Database.node])
+
 export * as AccountRepo from "./repo"

+ 3 - 0
packages/opencode/src/agent/agent.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { Config } from "@/config/config"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -430,4 +431,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Skill.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [Config.node, Auth.node, Plugin.node, Skill.node, Provider.node])
+
 export * as Agent from "./agent"

+ 3 - 0
packages/opencode/src/auth/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import path from "path"
 import { Effect, Layer, Record, Result, Schema, Context } from "effect"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -93,4 +94,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer))
 
+export const node = LayerNode.make(layer, [FSUtil.node])
+
 export * as Auth from "."

+ 3 - 0
packages/opencode/src/background/job.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job"
 import { InstanceState } from "@/effect/instance-state"
 import { Effect, Layer } from "effect"
@@ -33,4 +34,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer
 
+export const node = LayerNode.make(layer, [])
+
 export * as BackgroundJob from "./job"

+ 3 - 0
packages/opencode/src/command/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { InstanceState } from "@/effect/instance-state"
 import { EffectBridge } from "@/effect/bridge"
 import type { InstanceContext } from "@/project/instance-context"
@@ -178,4 +179,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Skill.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node])
+
 export * as Command from "."

+ 4 - 0
packages/opencode/src/config/config.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import path from "path"
 import { pathToFileURL } from "url"
@@ -669,4 +671,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(FetchHttpClient.layer),
 )
 
+export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])
+
 export * as Config from "./config"

+ 14 - 0
packages/opencode/src/control-plane/workspace.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
@@ -972,4 +974,16 @@ function route(url: string | URL, path: string) {
   return next
 }
 
+export const node = LayerNode.make(layer, [
+  Auth.node,
+  Session.node,
+  SessionPrompt.node,
+  httpClient,
+  EventV2Bridge.node,
+  Vcs.node,
+  RuntimeFlags.node,
+  FSUtil.node,
+  Database.node,
+])
+
 export * as Workspace from "./workspace"

+ 3 - 0
packages/opencode/src/effect/runtime-flags.ts

@@ -73,4 +73,7 @@ export const layer = (overrides: Partial<Info> = {}) =>
 
 export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie)
 
+export const node = LayerNode.make(defaultLayer, [])
+
 export * as RuntimeFlags from "./runtime-flags"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"

+ 3 - 0
packages/opencode/src/env/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Context, Effect, Layer } from "effect"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { InstanceState } from "@/effect/instance-state"
@@ -37,4 +38,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer
 
+export const node = LayerNode.make(layer, [])
+
 export * as Env from "."

+ 3 - 0
packages/opencode/src/event-v2-bridge.ts

@@ -1,5 +1,6 @@
 // Opencode publish boundary for core events. Attach routed instance location
 // so direct EventV2 consumers can isolate directory/workspace streams.
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
 import { GlobalBus } from "@/bus/global"
 import { EventV2 } from "@opencode-ai/core/event"
@@ -73,4 +74,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
 
+export const node = LayerNode.make(layer, [EventV2.node])
+
 export * as EventV2Bridge from "./event-v2-bridge"

+ 3 - 0
packages/opencode/src/format/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Effect, Layer, Context, Schema } from "effect"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { ChildProcess } from "effect/unstable/process"
@@ -199,4 +200,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(RuntimeFlags.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [Config.node, AppProcess.node, RuntimeFlags.node])
+
 export * as Format from "."

+ 3 - 0
packages/opencode/src/git/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { AppProcess } from "@opencode-ai/core/process"
 import { Effect, Layer, Context, Stream } from "effect"
 import { ChildProcess } from "effect/unstable/process"
@@ -344,4 +345,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer))
 
+export const node = LayerNode.make(layer, [AppProcess.node])
+
 export * as Git from "."

+ 3 - 0
packages/opencode/src/image/image.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Config } from "@/config/config"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import type { MessageV2 } from "@/session/message-v2"
@@ -168,4 +169,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
 
+export const node = LayerNode.make(layer, [Config.node])
+
 export * as Image from "./image"

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

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import { Effect, Layer, Schema, Context, Stream } from "effect"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -343,4 +345,6 @@ export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((
 export const method = () => runPromise((s) => s.method())
 export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
 
+export const node = LayerNode.make(layer, [httpClient, AppProcess.node])
+
 export * as Installation from "."

+ 4 - 0
packages/opencode/src/lsp/lsp.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { FSUtil } from "@opencode-ai/core/fs-util"
 import { EventV2Bridge } from "@/event-v2-bridge"
 import { EventV2 } from "@opencode-ai/core/event"
 import * as LSPClient from "./client"
@@ -504,4 +506,6 @@ export const defaultLayer = layer.pipe(
 
 export * as Diagnostic from "./diagnostic"
 
+export const node = LayerNode.make(layer, [Config.node, RuntimeFlags.node, FSUtil.node, EventV2Bridge.node])
+
 export * as LSP from "./lsp"

+ 3 - 0
packages/opencode/src/mcp/auth.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import path from "path"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { Global } from "@opencode-ai/core/global"
@@ -168,4 +169,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer))
 
+export const node = LayerNode.make(layer, [FSUtil.node, EffectFlock.node])
+
 export * as McpAuth from "./auth"

+ 3 - 0
packages/opencode/src/mcp/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -1003,4 +1004,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(FSUtil.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [CrossSpawnSpawner.node, McpAuth.node, EventV2Bridge.node, Config.node])
+
 export * as MCP from "."

+ 3 - 0
packages/opencode/src/permission/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
 import { InstanceState } from "@/effect/instance-state"
 import { Wildcard } from "@opencode-ai/core/util/wildcard"
@@ -224,4 +225,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<st
 
 export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
 
+export const node = LayerNode.make(layer, [EventV2Bridge.node])
+
 export * as Permission from "."

+ 3 - 0
packages/opencode/src/plugin/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import type {
   Hooks,
   PluginInput,
@@ -307,4 +308,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(RuntimeFlags.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [EventV2Bridge.node, Config.node, RuntimeFlags.node])
+
 export * as Plugin from "."

+ 13 - 0
packages/opencode/src/project/bootstrap.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Plugin } from "../plugin"
 import { Format } from "../format"
 import { LSP } from "@/lsp/lsp"
@@ -74,4 +75,16 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
   ]),
 )
 
+export const node = LayerNode.make(layer, [
+  Config.node,
+  Format.node,
+  LSP.node,
+  Plugin.node,
+  Project.node,
+  Search.node,
+  ShareNext.node,
+  Snapshot.node,
+  Vcs.node,
+])
+
 export * as InstanceBootstrap from "./bootstrap"

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

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { GlobalBus } from "@/bus/global"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { WorkspaceContext } from "@/control-plane/workspace-context"
@@ -7,6 +8,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
 import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
 import { type InstanceContext } from "./instance-context"
 import { InstanceBootstrap } from "./bootstrap-service"
+import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap"
 import * as Project from "./project"
 
 export interface LoadInput {
@@ -202,4 +204,6 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
 
 export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
 
+export const node = LayerNode.make(layer, [Project.node, InstanceBootstrapGraph.node])
+
 export * as InstanceStore from "./instance-store"

+ 12 - 0
packages/opencode/src/project/project.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { and, eq, sql } from "drizzle-orm"
 import { Database } from "@opencode-ai/core/database/database"
 import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
@@ -514,4 +515,15 @@ export const defaultLayer = layer.pipe(
 
 export const use = serviceUse(Service)
 
+export const node = LayerNode.make(layer, [
+  FSUtil.node,
+  AppProcess.node,
+  CrossSpawnSpawner.node,
+  ProjectV2.node,
+  ProjectCopy.node,
+  EventV2Bridge.node,
+  RuntimeFlags.node,
+  Database.node,
+])
+
 export * as Project from "./project"

+ 3 - 0
packages/opencode/src/project/vcs.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
 import { formatPatch, structuredPatch } from "diff"
 import { InstanceState } from "@/effect/instance-state"
@@ -425,4 +426,6 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
 
 export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
 
+export const node = LayerNode.make(layer, [Git.node, EventV2Bridge.node])
+
 export * as Vcs from "./vcs"

+ 3 - 0
packages/opencode/src/provider/auth.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { Auth } from "@/auth"
@@ -227,4 +228,6 @@ export const defaultLayer = Layer.suspend(() =>
   layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
 )
 
+export const node = LayerNode.make(layer, [Auth.node, Plugin.node])
+
 export * as ProviderAuth from "./auth"

+ 11 - 0
packages/opencode/src/provider/provider.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import os from "os"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
 import fuzzysort from "fuzzysort"
@@ -1948,4 +1949,14 @@ export function parseModel(model: string) {
   }
 }
 
+export const node = LayerNode.make(layer, [
+  FSUtil.node,
+  Config.node,
+  Auth.node,
+  Env.node,
+  Plugin.node,
+  ModelsDev.node,
+  RuntimeFlags.node,
+])
+
 export * as Provider from "./provider"

+ 3 - 0
packages/opencode/src/question/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Deferred, Effect, Layer, Schema, Context } from "effect"
 import { InstanceState } from "@/effect/instance-state"
 import { SessionID, MessageID } from "@/session/schema"
@@ -223,4 +224,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
 
+export const node = LayerNode.make(layer, [EventV2Bridge.node])
+
 export * as Question from "."

+ 12 - 0
packages/opencode/src/session/compaction.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
 import { Session } from "./session"
@@ -605,4 +606,15 @@ export const defaultLayer = Layer.suspend(() =>
   ),
 )
 
+export const node = LayerNode.make(layer, [
+  Config.node,
+  Session.node,
+  Agent.node,
+  Plugin.node,
+  SessionProcessor.node,
+  Provider.node,
+  EventV2Bridge.node,
+  RuntimeFlags.node,
+])
+
 export * as SessionCompaction from "./compaction"

+ 4 - 0
packages/opencode/src/session/instruction.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import path from "path"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { Effect, Layer, Context } from "effect"
@@ -234,4 +236,6 @@ export function loaded(messages: SessionV1.WithParts[]) {
   return extract(messages)
 }
 
+export const node = LayerNode.make(layer, [Config.node, FSUtil.node, Global.node, RuntimeFlags.node, httpClient])
+
 export * as Instruction from "./instruction"

+ 13 - 0
packages/opencode/src/session/llm.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { llmClient } from "@opencode-ai/core/effect/layer-node-platform"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { Provider } from "@/provider/provider"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
@@ -399,4 +401,15 @@ export const defaultLayer = Layer.suspend(() =>
 
 export const hasToolCalls = LLMRequestPrep.hasToolCalls
 
+export const node = LayerNode.make(layer, [
+  Auth.node,
+  Config.node,
+  Provider.node,
+  Plugin.node,
+  Permission.node,
+  EventV2Bridge.node,
+  llmClient,
+  RuntimeFlags.node,
+])
+
 export * as LLM from "./llm"

+ 17 - 0
packages/opencode/src/session/processor.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { Image } from "@/image/image"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
@@ -1064,4 +1065,20 @@ export const defaultLayer = Layer.suspend(() =>
   ),
 )
 
+export const node = LayerNode.make(layer, [
+  Session.node,
+  Config.node,
+  Snapshot.node,
+  Agent.node,
+  LLM.node,
+  Permission.node,
+  Plugin.node,
+  SessionSummary.node,
+  SessionStatus.node,
+  Image.node,
+  EventV2Bridge.node,
+  RuntimeFlags.node,
+  Database.node,
+])
+
 export * as SessionProcessor from "./processor"

+ 30 - 0
packages/opencode/src/session/prompt.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import path from "path"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
@@ -1671,4 +1672,33 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
 const placeholderRegex = /\$(\d+)/g
 const quoteTrimRegex = /^["']|["']$/g
 
+export const node = LayerNode.make(layer, [
+  SessionStatus.node,
+  Session.node,
+  Agent.node,
+  Provider.node,
+  SessionProcessor.node,
+  SessionCompaction.node,
+  Plugin.node,
+  Command.node,
+  Config.node,
+  Permission.node,
+  FSUtil.node,
+  MCP.node,
+  LSP.node,
+  ToolRegistry.node,
+  Truncate.node,
+  Image.node,
+  CrossSpawnSpawner.node,
+  Instruction.node,
+  SessionRunState.node,
+  SessionRevert.node,
+  SessionSummary.node,
+  SystemPrompt.node,
+  LLM.node,
+  EventV2Bridge.node,
+  RuntimeFlags.node,
+  Database.node,
+])
+
 export * as SessionPrompt from "./prompt"

+ 10 - 0
packages/opencode/src/session/revert.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Effect, Layer, Context, Schema } from "effect"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { EventV2Bridge } from "@/event-v2-bridge"
@@ -147,4 +148,13 @@ export const defaultLayer = Layer.suspend(() =>
   ),
 )
 
+export const node = LayerNode.make(layer, [
+  Session.node,
+  Snapshot.node,
+  Storage.node,
+  EventV2Bridge.node,
+  SessionSummary.node,
+  SessionRunState.node,
+])
+
 export * as SessionRevert from "./revert"

+ 3 - 0
packages/opencode/src/session/run-state.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { InstanceState } from "@/effect/instance-state"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { Runner } from "@/effect/runner"
@@ -150,4 +151,6 @@ function busyError(sessionID: SessionID) {
   return new Session.BusyError({ sessionID })
 }
 
+export const node = LayerNode.make(layer, [BackgroundJob.node, SessionStatus.node])
+
 export * as SessionRunState from "./run-state"

+ 3 - 0
packages/opencode/src/session/session.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { PermissionV1 } from "@opencode-ai/core/v1/permission"
 import { Slug } from "@opencode-ai/core/util/slug"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
@@ -1113,4 +1114,6 @@ export function* listGlobal(input?: {
   }
 }
 
+export const node = LayerNode.make(layer, [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node])
+
 export * as Session from "./session"

+ 3 - 0
packages/opencode/src/session/status.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { InstanceState } from "@/effect/instance-state"
 import { SessionID } from "./schema"
 import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -91,4 +92,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
 
+export const node = LayerNode.make(layer, [EventV2Bridge.node])
+
 export * as SessionStatus from "./status"

+ 3 - 0
packages/opencode/src/session/summary.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Effect, Layer, Context, Schema } from "effect"
 import { SessionV1 } from "@opencode-ai/core/v1/session"
 import { EventV2Bridge } from "@/event-v2-bridge"
@@ -159,4 +160,6 @@ export const DiffInput = Schema.Struct({
 })
 export type DiffInput = Schema.Schema.Type<typeof DiffInput>
 
+export const node = LayerNode.make(layer, [Session.node, Snapshot.node, EventV2Bridge.node, Config.node])
+
 export * as SessionSummary from "./summary"

+ 3 - 0
packages/opencode/src/session/system.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Context, Effect, Layer } from "effect"
 
 import { InstanceState } from "@/effect/instance-state"
@@ -81,4 +82,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
 
+export const node = LayerNode.make(layer, [Skill.node])
+
 export * as SystemPrompt from "./system"

+ 3 - 0
packages/opencode/src/session/todo.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { SessionID } from "./schema"
 import { Effect, Layer, Context, Schema } from "effect"
 import { Database } from "@opencode-ai/core/database/database"
@@ -84,4 +85,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer))
 
+export const node = LayerNode.make(layer, [EventV2Bridge.node, Database.node])
+
 export * as Todo from "./todo"

+ 3 - 0
packages/opencode/src/share/session.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Session } from "@/session/session"
 import { SessionID } from "@/session/schema"
 import { Effect, Layer, Scope, Context } from "effect"
@@ -55,4 +56,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(RuntimeFlags.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [Config.node, Session.node, ShareNext.node, RuntimeFlags.node])
+
 export * as SessionShare from "./session"

+ 12 - 0
packages/opencode/src/share/share-next.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
 import type * as SDK from "@opencode-ai/sdk/v2"
 import { serviceUse } from "@opencode-ai/core/effect/service-use"
 import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
@@ -370,4 +372,14 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Session.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [
+  Account.node,
+  EventV2Bridge.node,
+  Config.node,
+  Database.node,
+  httpClient,
+  Provider.node,
+  Session.node,
+])
+
 export * as ShareNext from "./share-next"

+ 4 - 0
packages/opencode/src/skill/discovery.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient, path } from "@opencode-ai/core/effect/layer-node-platform"
 import { NodePath } from "@effect/platform-node"
 import { Effect, Layer, Path, Schema, Context } from "effect"
 import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
@@ -102,4 +104,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
   Layer.provide(NodePath.layer),
 )
 
+export const node = LayerNode.make(layer, [FSUtil.node, path, httpClient])
+
 export * as Discovery from "./discovery"

+ 10 - 0
packages/opencode/src/skill/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import path from "path"
 import { pathToFileURL } from "url"
 import { Effect, Layer, Context, Schema } from "effect"
@@ -353,4 +354,13 @@ export function fmt(list: Info[], opts: { verbose: boolean }) {
   ].join("\n")
 }
 
+export const node = LayerNode.make(layer, [
+  Discovery.node,
+  Config.node,
+  EventV2Bridge.node,
+  FSUtil.node,
+  Global.node,
+  RuntimeFlags.node,
+])
+
 export * as Skill from "."

+ 3 - 0
packages/opencode/src/snapshot/index.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect"
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
 import { formatPatch, structuredPatch } from "diff"
@@ -756,4 +757,6 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Config.defaultLayer),
 )
 
+export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node, Config.node])
+
 export * as Snapshot from "."

+ 3 - 0
packages/opencode/src/storage/storage.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import path from "path"
 import { Global } from "@opencode-ai/core/global"
 import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -323,4 +324,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer))
 
+export const node = LayerNode.make(layer, [FSUtil.node, Git.node])
+
 export * as Storage from "./storage"

+ 27 - 0
packages/opencode/src/tool/registry.ts

@@ -1,3 +1,6 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { httpClient } from "@opencode-ai/core/effect/layer-node-platform"
+import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
 import { PlanExitTool } from "./plan"
 import { Session } from "@/session/session"
 import { QuestionTool } from "./question"
@@ -437,4 +440,28 @@ function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
   return typeof value === "object" && value !== null && !Array.isArray(value)
 }
 
+export const node = LayerNode.make(layer, [
+  Config.node,
+  Plugin.node,
+  Question.node,
+  Todo.node,
+  Agent.node,
+  Skill.node,
+  Session.node,
+  BackgroundJob.node,
+  Provider.node,
+  LSP.node,
+  Instruction.node,
+  FSUtil.node,
+  EventV2Bridge.node,
+  httpClient,
+  CrossSpawnSpawner.node,
+  Ripgrep.node,
+  Search.node,
+  Format.node,
+  Truncate.node,
+  RuntimeFlags.node,
+  Database.node,
+])
+
 export * as ToolRegistry from "./registry"

+ 3 - 0
packages/opencode/src/tool/truncate.ts

@@ -1,3 +1,4 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { NodePath } from "@effect/platform-node"
 import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
 import path from "path"
@@ -152,4 +153,6 @@ export const layer = Layer.effect(
 
 export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer))
 
+export const node = LayerNode.make(layer, [FSUtil.node])
+
 export * as Truncate from "./truncate"

+ 12 - 0
packages/opencode/src/worktree/index.ts

@@ -1,3 +1,5 @@
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { path } from "@opencode-ai/core/effect/layer-node-platform"
 import { Global } from "@opencode-ai/core/global"
 import { InstanceLayer } from "@/project/instance-layer"
 import { InstanceStore } from "@/project/instance-store"
@@ -639,4 +641,14 @@ export const appLayer = layer.pipe(
 
 export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer))
 
+export const node = LayerNode.make(layer, [
+  FSUtil.node,
+  path,
+  AppProcess.node,
+  Git.node,
+  Project.node,
+  InstanceStore.node,
+  Database.node,
+])
+
 export * as Worktree from "."

+ 101 - 0
packages/opencode/test/effect/app-graph-types.test.ts

@@ -0,0 +1,101 @@
+import { test } from "bun:test"
+import { Context, Effect, Layer } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+
+class A extends Context.Service<A, { readonly value: "a" }>()("test/A") {}
+class B extends Context.Service<B, { readonly value: "b" }>()("test/B") {}
+class C extends Context.Service<C, { readonly value: "c" }>()("test/C") {}
+class LayerError {
+  readonly _tag = "LayerError"
+}
+class NotFoundError {
+  readonly _tag = "NotFoundError"
+}
+class DiskError {
+  readonly _tag = "DiskError"
+}
+class NetworkError {
+  readonly _tag = "NetworkError"
+}
+
+const aImplementation = Layer.succeed(A, A.of({ value: "a" }))
+const bImplementation = Layer.effect(B, Effect.gen(function* () {
+  yield* A
+  return B.of({ value: "b" })
+}))
+const cImplementation = Layer.effect(C, Effect.gen(function* () {
+  yield* A
+  yield* B
+  return C.of({ value: "c" })
+}))
+const failingAImplementation = Layer.effect(A, Effect.fail(new LayerError()))
+const notFoundAImplementation = Layer.effect(A, Effect.fail(new NotFoundError()))
+const diskAImplementation = Layer.effect(A, Effect.fail(new DiskError()))
+const networkAImplementation = Layer.effect(A, Effect.fail(new NetworkError()))
+const notFoundOrDiskAImplementation = Layer.effect(
+  A,
+  Effect.fail(new NotFoundError() as NotFoundError | DiskError),
+)
+
+type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false
+type Assert<T extends true> = T
+
+type AProvides = Assert<Equal<Layer.Success<typeof aImplementation>, A>>
+type ARequires = Assert<Equal<Layer.Services<typeof aImplementation>, never>>
+type BProvides = Assert<Equal<Layer.Success<typeof bImplementation>, B>>
+type BRequires = Assert<Equal<Layer.Services<typeof bImplementation>, A>>
+type CRequires = Assert<Equal<Layer.Services<typeof cImplementation>, A | B>>
+void (0 as unknown as AProvides)
+void (0 as unknown as ARequires)
+void (0 as unknown as BProvides)
+void (0 as unknown as BRequires)
+void (0 as unknown as CRequires)
+
+const a = LayerNode.make(aImplementation, [])
+const b = LayerNode.make(bImplementation, [a])
+const c = LayerNode.make(cImplementation, [a, b])
+const failingA = LayerNode.make(failingAImplementation, [])
+const bWithFailingA = LayerNode.make(bImplementation, [failingA])
+const notFoundA = LayerNode.make(notFoundAImplementation, [])
+const diskA = LayerNode.make(diskAImplementation, [])
+const networkA = LayerNode.make(networkAImplementation, [])
+const notFoundOrDiskA = LayerNode.make(notFoundOrDiskAImplementation, [])
+
+// @ts-expect-error B requires A
+LayerNode.make(bImplementation, [])
+
+// @ts-expect-error C requires both A and B
+LayerNode.make(cImplementation, [a])
+
+type ANodeProvides = Assert<Equal<typeof a, LayerNode.Node<A, never>>>
+type BNodeProvides = Assert<Equal<typeof b, LayerNode.Node<B, never>>>
+type CNodeProvides = Assert<Equal<typeof c, LayerNode.Node<C, never>>>
+type FailingANodeError = Assert<Equal<typeof failingA, LayerNode.Node<A, LayerError>>>
+type DependentNodeError = Assert<Equal<typeof bWithFailingA, LayerNode.Node<B, LayerError>>>
+void (0 as unknown as ANodeProvides)
+void (0 as unknown as BNodeProvides)
+void (0 as unknown as CNodeProvides)
+void (0 as unknown as FailingANodeError)
+void (0 as unknown as DependentNodeError)
+
+const closed = LayerNode.buildLayer(c)
+const closedWithError = LayerNode.buildLayer(bWithFailingA)
+type ClosedProvides = Assert<Equal<Layer.Success<typeof closed>, C>>
+type ClosedRequires = Assert<Equal<Layer.Services<typeof closed>, never>>
+type ClosedError = Assert<Equal<Layer.Error<typeof closedWithError>, LayerError>>
+void (0 as unknown as ClosedProvides)
+void (0 as unknown as ClosedRequires)
+void (0 as unknown as ClosedError)
+
+const replacement = LayerNode.make(Layer.succeed(A, A.of({ value: "a" })), [])
+LayerNode.replace(a, replacement)
+LayerNode.replace(notFoundOrDiskA, notFoundA)
+LayerNode.replace(notFoundOrDiskA, diskA)
+
+// @ts-expect-error An override for A must still provide A
+LayerNode.replace(a, b)
+
+// @ts-expect-error A replacement cannot introduce NetworkError
+LayerNode.replace(notFoundOrDiskA, networkA)
+
+test("type exploration compiles", () => {})

+ 208 - 0
packages/opencode/test/effect/app-graph.test.ts

@@ -0,0 +1,208 @@
+import { describe, expect, test } from "bun:test"
+import { Cause, Context, Effect, Exit, Layer } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+
+const { buildLayer: build, group, replace } = LayerNode
+const node = LayerNode.make
+
+class Value extends Context.Service<Value, { readonly value: string }>()("test/Value") {}
+class Greeting extends Context.Service<Greeting, { readonly text: string }>()("test/Greeting") {}
+
+const value = LayerNode.make(Layer.succeed(Value, Value.of({ value: "production" })), [])
+const greetingImplementation = Layer.effect(
+  Greeting,
+  Effect.gen(function* () {
+    return Greeting.of({ text: `hello ${(yield* Value).value}` })
+  }),
+)
+const greeting = LayerNode.make(greetingImplementation, [value])
+
+// @ts-expect-error Greeting requires Value
+LayerNode.make(greetingImplementation, [])
+
+describe("app graph", () => {
+  test("creates any selected dependency layer", async () => {
+    const result = Effect.gen(function* () {
+      return (yield* Greeting).text
+    }).pipe(Effect.provide(build(greeting)))
+
+    expect(await Effect.runPromise(result)).toBe("hello production")
+  })
+
+  test("applies overrides before dependency materialization", async () => {
+    const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), [])
+    const graph = build(greeting, { replacements: [replace(value, replacement)] })
+    const result = Effect.gen(function* () {
+      return (yield* Greeting).text
+    }).pipe(Effect.provide(graph))
+
+    expect(await Effect.runPromise(result)).toBe("hello simulation")
+  })
+
+  test("acquires a shared dependency once", async () => {
+    class Shared extends Context.Service<Shared, { readonly value: string }>()("test/Shared") {}
+    class Left extends Context.Service<Left, { readonly value: string }>()("test/Left") {}
+    class Right extends Context.Service<Right, { readonly value: string }>()("test/Right") {}
+    let acquisitions = 0
+    const shared = node(
+      Layer.effect(
+        Shared,
+        Effect.sync(() => {
+          acquisitions++
+          return Shared.of({ value: "shared" })
+        }),
+      ),
+      [],
+    )
+    const left = node(
+      Layer.effect(
+        Left,
+        Effect.gen(function* () {
+          return Left.of({ value: `${(yield* Shared).value}-left` })
+        }),
+      ),
+      [shared],
+    )
+    const right = node(
+      Layer.effect(
+        Right,
+        Effect.gen(function* () {
+          return Right.of({ value: `${(yield* Shared).value}-right` })
+        }),
+      ),
+      [shared],
+    )
+
+    const result = Effect.gen(function* () {
+      return [(yield* Left).value, (yield* Right).value]
+    }).pipe(Effect.provide(build(group([left, right]))))
+
+    expect(await Effect.runPromise(result)).toEqual(["shared-left", "shared-right"])
+    expect(acquisitions).toBe(1)
+  })
+
+  test("applies a replacement to every transitive consumer", async () => {
+    class Left extends Context.Service<Left, { readonly value: string }>()("test/ReplacementLeft") {}
+    class Right extends Context.Service<Right, { readonly value: string }>()("test/ReplacementRight") {}
+    const left = node(
+      Layer.effect(
+        Left,
+        Effect.gen(function* () {
+          return Left.of({ value: (yield* Value).value })
+        }),
+      ),
+      [value],
+    )
+    const right = node(
+      Layer.effect(
+        Right,
+        Effect.gen(function* () {
+          return Right.of({ value: (yield* Value).value })
+        }),
+      ),
+      [value],
+    )
+    const replacement = node(Layer.succeed(Value, Value.of({ value: "simulation" })), [])
+    const graph = build(group([left, right]), { replacements: [replace(value, replacement)] })
+
+    const result = Effect.gen(function* () {
+      return [(yield* Left).value, (yield* Right).value]
+    }).pipe(Effect.provide(graph))
+
+    expect(await Effect.runPromise(result)).toEqual(["simulation", "simulation"])
+  })
+
+  test("propagates layer acquisition errors", async () => {
+    class AcquisitionError {
+      readonly _tag = "AcquisitionError"
+    }
+    const failing = node(Layer.effect(Value, Effect.fail(new AcquisitionError())), [])
+    const exit = await Effect.runPromiseExit(Effect.provide(Value, build(failing)))
+
+    expect(Exit.isFailure(exit)).toBe(true)
+    if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(AcquisitionError)
+  })
+
+  test("groups expose every selected service", async () => {
+    class Count extends Context.Service<Count, { readonly value: number }>()("test/Count") {}
+    const count = node(Layer.succeed(Count, Count.of({ value: 3 })), [])
+    const result = Effect.gen(function* () {
+      return { text: (yield* Value).value, count: (yield* Count).value }
+    }).pipe(Effect.provide(build(group([value, count]))))
+
+    expect(await Effect.runPromise(result)).toEqual({ text: "production", count: 3 })
+  })
+
+  test("builds an empty group", async () => {
+    expect(await Effect.runPromise(Effect.succeed("ok").pipe(Effect.provide(build(group([])))))).toBe("ok")
+  })
+
+  test("builds replacements with their own dependencies", async () => {
+    class ReplacementConfig extends Context.Service<ReplacementConfig, { readonly value: string }>()(
+      "test/ReplacementConfig",
+    ) {}
+    const replacementConfig = node(
+      Layer.succeed(ReplacementConfig, ReplacementConfig.of({ value: "replacement" })),
+      [],
+    )
+    const replacement = node(
+      Layer.effect(
+        Value,
+        Effect.gen(function* () {
+          return Value.of({ value: (yield* ReplacementConfig).value })
+        }),
+      ),
+      [replacementConfig],
+    )
+    const result = Effect.gen(function* () {
+      return (yield* Greeting).text
+    }).pipe(Effect.provide(build(greeting, { replacements: [replace(value, replacement)] })))
+
+    expect(await Effect.runPromise(result)).toBe("hello replacement")
+  })
+
+  test("does not acquire unreachable replacements", async () => {
+    let acquisitions = 0
+    const unreachable = node(Layer.succeed(Value, Value.of({ value: "unreachable" })), [])
+    const replacement = node(
+      Layer.effect(
+        Value,
+        Effect.sync(() => {
+          acquisitions++
+          return Value.of({ value: "replacement" })
+        }),
+      ),
+      [],
+    )
+
+    await Effect.runPromise(Effect.provide(Greeting, build(greeting, { replacements: [replace(unreachable, replacement)] })))
+
+    expect(acquisitions).toBe(0)
+  })
+
+  test("rejects a direct cycle", () => {
+    const cyclic = node(Layer.succeed(Value, Value.of({ value: "cyclic" })), [])
+    ;(cyclic.dependencies as LayerNode.Node<unknown, unknown>[]).push(cyclic)
+
+    expect(() => build(cyclic)).toThrow("Cycle detected in app graph: layer#1 -> layer#1")
+  })
+
+  test("rejects an indirect cycle", () => {
+    const first = node(Layer.succeed(Value, Value.of({ value: "first" })), [])
+    const second = node(Layer.succeed(Value, Value.of({ value: "second" })), [first])
+    const third = node(Layer.succeed(Value, Value.of({ value: "third" })), [second])
+    ;(first.dependencies as LayerNode.Node<unknown, unknown>[]).push(third)
+
+    expect(() => build(first)).toThrow("Cycle detected in app graph: layer#1 -> layer#2 -> layer#3 -> layer#1")
+  })
+
+  test("rejects a cycle introduced by a replacement", () => {
+    const replacement = node(Layer.succeed(Value, Value.of({ value: "replacement" })), [])
+    const consumer = node(greetingImplementation, [value])
+    ;(replacement.dependencies as LayerNode.Node<unknown, unknown>[]).push(consumer)
+
+    expect(() => build(consumer, { replacements: [replace(value, replacement)] })).toThrow(
+      "Cycle detected in app graph: layer#1 -> layer#2 -> layer#1",
+    )
+  })
+})