From 8541b39cb92687edf1cbf875ade78dc55a22d4f3 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 18:06:46 -0400 Subject: [PATCH 1/2] refactor(opencode): build runtimes from layer nodes --- packages/opencode/src/acp/service.ts | 25 +++-- packages/opencode/src/cli/cmd/debug/scrap.ts | 3 +- packages/opencode/src/config/tui.ts | 8 +- packages/opencode/src/effect/app-runtime.ts | 102 +++++++++--------- .../opencode/src/effect/bootstrap-runtime.ts | 12 +-- packages/opencode/src/installation/index.ts | 11 +- packages/opencode/src/plugin/tui/runtime.ts | 3 +- .../instance/httpapi/websocket-tracker.ts | 5 +- packages/opencode/src/server/server.ts | 3 +- packages/opencode/test/config/tui.test.ts | 5 +- 10 files changed, 95 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index f15047f78ffc..63d075f3fcc5 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -30,6 +30,7 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPError from "./error" @@ -569,22 +570,26 @@ export function make(input: { } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(AppNodeBuilder.build(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } function makeDirectoryService(sdk: OpencodeClient) { return ManagedRuntime.make( - Directory.layer.pipe( - Layer.provide( - Layer.succeed( - Directory.Loader, - Directory.Loader.of({ - load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), - }), - ), - ), + AppNodeBuilder.build( + Directory.node, + [ + [ + Directory.loaderNode, + Layer.succeed( + Directory.Loader, + Directory.Loader.of({ + load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), + }), + ), + ], + ], ), ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index eef6e0556964..f624ee882e4c 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -7,8 +7,9 @@ export const ScrapCommand = cmd({ builder: (yargs) => yargs, async handler() { const { Project } = await import("@/project/project") + const { AppNodeBuilder } = await import("@opencode-ai/core/effect/app-node-builder") const { makeRuntime } = await import("@opencode-ai/core/effect/runtime") - const runtime = makeRuntime(Project.Service, Project.defaultLayer) + const runtime = makeRuntime(Project.Service, AppNodeBuilder.build(Project.node)) const list = await runtime.runPromise((project) => project.list()) process.stdout.write(JSON.stringify(list, null, 2) + EOL) }, diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index edc7674a9309..8f503751f379 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -2,6 +2,8 @@ export * as TuiConfig from "./tui" import path from "path" import { mergeDeep, unique } from "remeda" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" @@ -223,7 +225,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const directory = yield* CurrentWorkingDirectory @@ -257,9 +259,9 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] }) -const { runPromise } = makeRuntime(Service, defaultLayer) +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) export async function waitForDependencies() { await runPromise((svc) => svc.waitForDependencies()) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index a31ff70ea1b3..44d642e4045b 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -53,57 +53,61 @@ import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -export const AppLayer = Layer.mergeAll( - Npm.defaultLayer, - FSUtil.defaultLayer, - Database.defaultLayer, - Auth.defaultLayer, - Account.defaultLayer, - Config.defaultLayer, - Git.defaultLayer, - Storage.defaultLayer, - Snapshot.defaultLayer, - Plugin.defaultLayer, - ModelsDev.defaultLayer, - Provider.defaultLayer, - ProviderAuth.defaultLayer, - Agent.defaultLayer, - Skill.defaultLayer, - Discovery.defaultLayer, - Question.defaultLayer, - Permission.defaultLayer, - Todo.defaultLayer, - Session.defaultLayer, - SessionStatus.defaultLayer, - BackgroundJob.defaultLayer, - RuntimeFlags.defaultLayer, - EventV2Bridge.defaultLayer, - SessionRunState.defaultLayer, - SessionProcessor.defaultLayer, - SessionCompaction.defaultLayer, - SessionRevert.defaultLayer, - SessionSummary.defaultLayer, - SessionPrompt.defaultLayer, - Instruction.defaultLayer, - LLM.defaultLayer, - LSP.defaultLayer, - MCP.defaultLayer, - McpAuth.defaultLayer, - Command.defaultLayer, - Truncate.defaultLayer, - ToolRegistry.defaultLayer, - Format.defaultLayer, - Project.defaultLayer, - Vcs.defaultLayer, - Workspace.defaultLayer, - Worktree.appLayer, - Installation.defaultLayer, - ShareNext.defaultLayer, - SessionShare.defaultLayer, +export const AppLayer = AppNodeBuilder.build( + LayerNode.group([ + Npm.node, + FSUtil.node, + Database.node, + Auth.node, + Account.node, + Config.node, + Git.node, + Storage.node, + Snapshot.node, + Plugin.node, + ModelsDev.node, + Provider.node, + ProviderAuth.node, + Agent.node, + Skill.node, + Discovery.node, + Question.node, + Permission.node, + Todo.node, + Session.node, + SessionStatus.node, + BackgroundJob.node, + RuntimeFlags.node, + EventV2Bridge.node, + SessionRunState.node, + SessionProcessor.node, + SessionCompaction.node, + SessionRevert.node, + SessionSummary.node, + SessionPrompt.node, + Instruction.node, + LLM.node, + LSP.node, + MCP.node, + McpAuth.node, + Command.node, + Truncate.node, + ToolRegistry.node, + Format.node, + InstanceStore.node, + Project.node, + Vcs.node, + Workspace.node, + Worktree.node, + Installation.node, + ShareNext.node, + SessionShare.node, + ]), + [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], ).pipe( - Layer.provideMerge(Ripgrep.defaultLayer), - Layer.provideMerge(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), + Layer.provideMerge(AppNodeBuilder.build(Ripgrep.node)), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index c1987a48ce94..57fe43b46ee3 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -1,4 +1,6 @@ import { Layer, ManagedRuntime } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "@/plugin" import { LSP } from "@/lsp/lsp" @@ -10,14 +12,8 @@ import { Config } from "@/config/config" import * as Observability from "@opencode-ai/core/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" -export const BootstrapLayer = Layer.mergeAll( - Config.defaultLayer, - Plugin.defaultLayer, - ShareNext.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Vcs.defaultLayer, - Snapshot.defaultLayer, +export const BootstrapLayer = AppNodeBuilder.build( + LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 698c27c09ac3..4300220255b3 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,8 +1,9 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { httpClient } from "@opencode-ai/core/effect/app-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" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" @@ -82,7 +83,7 @@ export class Service extends Context.Service()("@opencode/In export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -324,14 +325,12 @@ export const layer: Layer.Layer) => runPromise((s) => s.latest(...args)) export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [httpClient, AppProcess.node] }) - export * as Installation from "." diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 4673805cf3bc..2a88ad3d5934 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -14,6 +14,7 @@ import { import path from "path" import { fileURLToPath } from "url" import { TuiConfig } from "@/config/tui" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { errorData, errorMessage } from "@opencode-ai/tui/util/error" import { isRecord } from "@opencode-ai/tui/util/record" import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention" @@ -1082,7 +1083,7 @@ async function load(input: { const flags = await Effect.runPromise( Effect.gen(function* () { return yield* RuntimeFlags.Service - }).pipe(Effect.provide(RuntimeFlags.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(RuntimeFlags.node))), ) const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins()) const records = Flag.OPENCODE_PURE ? [] : pluginOrigins diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts index 7cbac4ed5f24..7e8eed89a4f7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -1,4 +1,5 @@ import { Context, Effect, Layer, Option } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import * as Socket from "effect/unstable/socket/Socket" export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") @@ -13,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} -export const layer = Layer.sync(Service)(() => { +const layer = Layer.sync(Service)(() => { const sockets = new Set() let closing = false return Service.of({ @@ -44,6 +45,8 @@ export const layer = Layer.sync(Service)(() => { }) }) +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + export const register = (close: Close) => Effect.gen(function* () { const tracker = yield* Effect.serviceOption(Service) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5145e26d6c49..440b992c1557 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,6 +1,7 @@ import "./init-projectors" import { NodeHttpServer } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" @@ -102,7 +103,7 @@ function listenerLayer(opts: ListenOptions, port: number) { disableLogger: true, disableListenLog: true, }).pipe( - Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(AppNodeBuilder.build(WebSocketTracker.node)), Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), // Install a fresh `ConfigProvider` per listener so `Config.string(...)` // reads reflect the current `process.env`. Effect's default diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 38413533c4d9..d00b4a4ad6b1 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -70,12 +71,12 @@ const withPlatform = (platform: typeof process.platform, self: Effect.E const getTuiConfig = (directory: string) => TuiConfig.Service.use((svc) => svc.get()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide(AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), ) const getTuiPluginOrigins = (directory: string) => TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide(AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), ) it.instance("keeps server and tui plugin merge semantics aligned", () => From 29c517a652c60890085a1c55ca78471daf81ac65 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 20:41:50 -0400 Subject: [PATCH 2/2] fix(opencode): include session projector in app runtime --- packages/opencode/src/effect/app-runtime.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 44d642e4045b..5048bbf501ee 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -54,6 +54,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { SessionProjector } from "@opencode-ai/core/session/projector" export const AppLayer = AppNodeBuilder.build( LayerNode.group([ @@ -77,6 +78,7 @@ export const AppLayer = AppNodeBuilder.build( Permission.node, Todo.node, Session.node, + SessionProjector.node, SessionStatus.node, BackgroundJob.node, RuntimeFlags.node,