Compare commits

...
Author SHA1 Message Date
Kit Langton 6ed2b1b545 refactor(core): rename SessionEnv to SessionEngine and simplify
SessionEnvironment already names per-session shell variables, so the
engine tier takes distinct vocabulary. Review fixes fold in: per-bind
release tokens, bindings scoped to the environment rather than the
caller, one pinned environment per busy period with the bound context
provided directly (no per-continuation layer build or store read),
shared compileWithHoistedGlobals used by both constructors,
SessionRunnerModel.fixed beside its resolved seam, exported Tool.Draft,
PluginSupervisor.noop naming, a typed make error channel, dropped dead
Handle.context surface, and a compile-time subset guard tying the
engine tier to the full location graph.
2026-08-26 16:42:41 -04:00
Kit Langton c25b2119f6 test(core): prove a values environment drives a real durable drain
A SessionEnv built from values (fixed model, one custom tool, upserted
build agent) admits a prompt through normal durable admission and drains
through the real SessionExecution path: the bound engine graph executes
the tool and persists assistant history in the application's Database,
with no discovery, plugins, MCP, or provider SDKs.
2026-08-26 16:28:58 -04:00
Kit Langton 8ebb94a8d1 feat(core): bind values-constructed environments to session drains
SessionEnvBindings maps Session IDs to live engine contexts; execution
resolves a bound context before the Session's Location graph. SessionEnv
becomes a global node that captures the root MemoMap at construction
(the LayerMap trick) so hoisted global nodes dedupe against the running
Database, Bus, and SessionStore. Its handle creates or adopts durable
sessions and scopes the binding to the caller.
2026-08-26 16:28:58 -04:00
Kit Langton 8cbe3fb785 feat(core): SessionEnv.make values constructor
Builds one live engine graph in the caller's scope from the sessionEnvGroup
with discovery, plugins, and MCP replaced, then populates Tool/Agent/Catalog
through the same draft APIs plugins use. Hoisted global nodes compile to the
application root's Layer references so memoization reuses the running
Database, Bus, and SessionStore.
2026-08-26 16:05:43 -04:00
Kit Langton 36d3c5e76f feat(core): replacement layers for MCP-free, plugin-free environments
PluginSupervisor.ready, McpTool.noop, and McpInstructions.noop let a
values-constructed engine graph settle immediately and drop the MCP and
config-discovery subtrees from its dependency closure.
2026-08-26 16:00:12 -04:00
Kit Langton 01a3b677aa refactor(core): name the session engine tier
Define sessionEnvNodes/SessionEnv alongside the full location graph: the
subset of nodes a session drain consumes, with dependency closure pulling
supporting nodes during compile. locationServiceNodes is untouched because
its list order is semantic (compile provide-merges in order).
2026-08-26 16:00:11 -04:00
12 changed files with 404 additions and 27 deletions
+51 -8
View File
@@ -56,6 +56,35 @@ import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js"
/**
* Engine tier: the tags consumed from OUTSIDE the graph by the drain and by
* session operations, plus the registries that form the configuration surface.
* Everything else the engine needs (SessionContext, ModelRequest, Permission,
* ModelResolver, ...) is internal wiring reached through dependency closure
* during compile, where replacements can substitute capability sources.
* `locationServiceNodes` below stays the composed full graph — its list order
* is semantic (compile provide-merges in order), so the tier is named
* alongside, not split out.
*/
const sessionEngineNodes = [
// drain entry (execution.ts runs the runner; its layer wires the spine internally)
SessionRunnerLLM.node,
// prompt admission (session.ts attachment resize + skill mentions) and readiness
PluginSupervisor.node,
Image.node,
Skill.node,
// configuration surface: populated from values instead of discovery
Tool.node,
Agent.node,
Catalog.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const sessionEngineGroup = LayerNode.group<typeof sessionEngineNodes>(sessionEngineNodes)
/** What a session drain and its operations require. `LocationServices` is a superset. */
export type SessionEngine = LayerNode.Output<typeof sessionEngineGroup>
export type SessionEngineError = LayerNode.Error<typeof sessionEngineGroup>
const locationServiceNodes = [
Location.node,
Environment.node,
@@ -112,6 +141,27 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
// Compile-time guard: the engine tier must remain a subset of the full graph.
const _sessionEngineIsSubset: [SessionEngine] extends [LocationServices] ? true : never = true
void _sessionEngineIsSubset
/**
* Compile a Location graph with its global nodes hoisted out. Replacements
* must be applied during hoist, not afterward: replacements can introduce new
* tagged dependencies (Location.boundNode depends on Project), and the hoist
* walk is the only pass that can still slice those back out. Callers must
* thread the application root's replacements through so hoisted globals
* compile to the same Layer references the root built and memoization dedupes
* them instead of constructing second instances.
*/
export function compileWithHoistedGlobals<A, E>(
root: LayerNode.Node<A, E, LayerNode.Tag | undefined>,
replacements: LayerNode.Replacements,
): Layer.Layer<A, E> {
const sliced = LayerNode.hoist(root, Node.tags.values.global, replacements)
return LayerNode.compile(sliced.node).pipe(Layer.fresh, Layer.provide(LayerNode.compile(sliced.hoisted)))
}
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
@@ -129,14 +179,8 @@ export function buildLocationServiceMap(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return LayerNode.compile(location.node).pipe(
Layer.fresh,
return compileWithHoistedGlobals(locationServices, allReplacements).pipe(
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
@@ -144,7 +188,6 @@ export function buildLocationServiceMap(
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{
+3
View File
@@ -60,6 +60,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
/** For environments without MCP: no server guidance to load. */
export const noop = Layer.succeed(Service, Service.of({ load: () => Effect.succeed(Instructions.empty) }))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -1,6 +1,6 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
import { Context, Effect, Layer } from "effect"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -12,3 +12,6 @@ export interface Interface {
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
/** For values-constructed environments: no plugin generations exist, so flush settles immediately. */
export const noop = Layer.succeed(Service, Service.of({ flush: Effect.void }))
+1 -1
View File
@@ -1,5 +1,5 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
export { noop, Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
+145
View File
@@ -0,0 +1,145 @@
export * as SessionEngine from "./session-engine.js"
import { Context, Effect, Layer, Scope } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "./agent.js"
import { Catalog } from "./catalog.js"
import { Location } from "./location.js"
import { McpInstructions } from "./mcp/instructions.js"
import { McpTool } from "./tool/mcp.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Session } from "./session.js"
import { SessionEngineBindings } from "./session/engine-bindings.js"
import { SessionRunnerModel } from "./session/runner/model.js"
import { SessionSchema } from "./session/schema.js"
import { Snapshot } from "./snapshot.js"
import { Tool } from "./tool.js"
import { compileWithHoistedGlobals, sessionEngineGroup, type SessionEngine, type SessionEngineError } from "./location-services.js"
import type { AbsolutePath } from "./schema.js"
/**
* Values-constructed session environment: the engine tier of the location
* graph, booted without discovery, plugins, or MCP. Capabilities arrive
* through the same draft APIs plugins use, so registry invariants (hook
* wiring, image normalization, permission gating) hold by construction.
*/
export interface Options {
readonly directory: AbsolutePath
/**
* Fixed model for every drain in this environment, bypassing catalog
* resolution (SessionRunnerModel.resolved is the values-side constructor).
* Omit to resolve through the populated catalog instead.
*/
readonly model?: SessionRunnerModel.Resolved
/** Capture filesystem snapshots around attempts. Defaults to false. */
readonly snapshots?: boolean
readonly tools?: (draft: Tool.Draft) => void
readonly agents?: (draft: Agent.Draft) => void
readonly catalog?: (draft: Catalog.Draft) => void
}
type PromptOptions = Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">
type SessionOptions = Omit<Parameters<Session.Interface["create"]>[0], "location" | "parentID">
export interface SessionHandle {
readonly id: SessionSchema.ID
readonly prompt: (input: PromptOptions) => ReturnType<Session.Interface["prompt"]>
readonly interrupt: (input?: { readonly continue?: boolean }) => Effect.Effect<boolean>
}
export interface Handle {
/**
* Ensure a durable session and bind it to this environment. Reusing a
* Session ID adopts the existing Session (creation args are ignored then),
* so reconnection after a restart is the same call with the same ID. The
* binding lives until the environment's scope closes; drains resolve the
* bound graph instead of the Session's Location graph.
*/
readonly session: (input?: SessionOptions) => Effect.Effect<SessionHandle, Session.NotFoundError>
}
export interface Interface {
readonly make: (options: Options) => Effect.Effect<Handle, SessionEngineError, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEngine") {}
/**
* Captures the application root's MemoMap at construction (the same trick
* LayerMap.make uses), so each environment's hoisted global nodes dedupe
* against the running Database, Bus, and SessionStore instead of building
* second instances. The engine subtree itself builds fresh per environment.
*
* Like buildLocationServiceMap, the layer must receive the application
* root's replacements: hoisted globals otherwise compile their original
* implementations and a composed root (test harness, embedded host) would
* build second, differently-configured instances.
*/
const layerWith = (base: LayerNode.Replacements) =>
Layer.effect(
Service,
Effect.gen(function* () {
const memoMap = Layer.CurrentMemoMap.forkOrCreate(yield* Effect.context<never>())
const bindings = yield* SessionEngineBindings.Service
const sessions = yield* Session.Service
const make = Effect.fn("SessionEngine.make")(function* (options: Options) {
const scope = yield* Effect.scope
const location = Location.Ref.make({ directory: options.directory })
// Later entries win in the replacement map, so environment-specific
// substitutions override same-node entries from the application root.
const replacements: LayerNode.Replacements = [
...base,
[Location.node, Location.boundNode(location)],
[PluginSupervisor.node, PluginSupervisor.noop],
[McpTool.node, McpTool.noop],
[McpInstructions.node, McpInstructions.noop],
...(options.snapshots === true ? [] : [[Snapshot.node, Snapshot.noopLayer] as const]),
...(options.model === undefined
? []
: [[SessionRunnerModel.node, SessionRunnerModel.fixed(options.model)] as const]),
]
const context = yield* Layer.buildWithMemoMap(
compileWithHoistedGlobals(sessionEngineGroup, replacements),
memoMap,
scope,
)
const populate = Effect.gen(function* () {
const tools = options.tools
if (tools) yield* Tool.Service.use((service) => service.transform(tools))
const agents = options.agents
if (agents) yield* Agent.Service.use((service) => service.transform(agents))
const catalog = options.catalog
if (catalog) yield* Catalog.Service.use((service) => service.transform(catalog))
})
yield* populate.pipe(Effect.provide(context), Effect.provideService(Scope.Scope, scope))
const session = Effect.fn("SessionEngine.session")(function* (input?: SessionOptions) {
// Create-or-adopt: ID reuse returns the existing durable Session, and the
// binding outranks its recorded Location even if the directories differ.
const info = yield* sessions.create({ ...input, location })
// Bind in the environment's scope: teardown must unbind every session so
// drains fall back to the Location graph instead of a torn-down context.
yield* bindings.bind(info.id, context).pipe(Effect.provideService(Scope.Scope, scope))
return {
id: info.id,
prompt: (promptInput: PromptOptions) => sessions.prompt({ ...promptInput, sessionID: info.id }),
interrupt: (interruptInput?: { readonly continue?: boolean }) =>
sessions.interrupt(info.id, interruptInput),
} as const
})
return { session } as const
})
return Service.of({ make })
}),
)
/** Thread the application root's replacements through, mirroring buildLocationServiceMap. */
export const configured = (replacements: LayerNode.Replacements = []) =>
makeGlobalNode({ service: Service, layer: layerWith(replacements), deps: [SessionEngineBindings.node, Session.node] })
export const node = configured()
@@ -0,0 +1,48 @@
export * as SessionEngineBindings from "./engine-bindings.js"
import { Context, Effect, Layer, Scope } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { SessionEngine } from "../location-services.js"
import { SessionSchema } from "./schema.js"
/**
* Process-local map from Session ID to a values-constructed engine graph.
* Execution resolves a bound context before falling back to the Session's
* Location graph, so tier-2 sessions drain against caller-supplied
* capabilities while every other session is untouched.
*/
export interface Interface {
/** Bind until the enclosing scope closes. Rebinding the same ID replaces the previous binding. */
readonly bind: (
id: SessionSchema.ID,
context: Context.Context<SessionEngine>,
) => Effect.Effect<void, never, Scope.Scope>
readonly get: (id: SessionSchema.ID) => Context.Context<SessionEngine> | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEngineBindings") {}
export const layer = Layer.sync(Service, () => {
// Entries wrap the context so release identity is per bind call: binding the
// same context twice from different scopes must not let the first release
// tear down the survivor's entry.
const map = new Map<SessionSchema.ID, { readonly context: Context.Context<SessionEngine> }>()
return Service.of({
bind: (id, context) =>
Effect.acquireRelease(
Effect.sync(() => {
const entry = { context }
map.set(id, entry)
return entry
}),
(entry) =>
Effect.sync(() => {
// A later rebind owns the entry now; do not tear it down.
if (map.get(id) === entry) map.delete(id)
}),
).pipe(Effect.asVoid),
get: (id) => map.get(id)?.context,
})
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+24 -14
View File
@@ -6,6 +6,7 @@ import { Database } from "../database/database.js"
import { Job } from "../job.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEngineBindings } from "./engine-bindings.js"
import { SessionEvent } from "./event.js"
import { SessionRunCoordinator } from "./run-coordinator.js"
import { SessionRunner } from "./runner/index.js"
@@ -52,6 +53,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bindings = yield* SessionEngineBindings.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
@@ -83,22 +85,30 @@ export const layer = Layer.effect(
continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> {
const loop = (
force: boolean,
continuation?: SessionRunner.Continuation,
): Effect.Effect<void, SessionRunner.RunError, SessionRunner.Service> =>
SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
Effect.flatMap((result) => (result._tag === "Complete" ? Effect.void : loop(false, result.continuation))),
)
return Effect.gen(function* () {
// The environment is resolved once and pinned for the whole busy period, so a
// binding change never switches environments between continuations. A bound
// values-constructed environment outranks the Session's Location graph and
// implies the Session exists, since binding follows durable creation.
const bound = bindings.get(sessionID)
if (bound) return yield* loop(force, continuation).pipe(Effect.provide(bound))
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
if (result._tag === "Complete") return
return yield* drain(sessionID, false, result.continuation, promotable)
})
return yield* loop(force, continuation).pipe(Effect.provide(locations.get(session.location)))
}).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
@@ -170,7 +180,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, LocationServiceMap.node, SessionEngineBindings.node, Bus.node, Database.node, Job.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -67,6 +67,10 @@ export const resolved = (
limit: options.limit,
})
/** Layer resolving every session to one fixed model, bypassing the catalog. Test or embedding seam. */
export const fixed = (resolved: Resolved) =>
Layer.succeed(Service, Service.of({ resolve: () => Effect.succeed(resolved) }))
const layer = Layer.effect(
Service,
Effect.gen(function* () {
+5 -3
View File
@@ -22,10 +22,12 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Draft {
readonly add: (tool: Tool.Info) => void
}
export interface Interface {
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, never, Scope.Scope>
readonly transform: (callback: (draft: Draft) => void) => Effect.Effect<void, never, Scope.Scope>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
+3
View File
@@ -23,6 +23,9 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
/** For environments without MCP: registration settles immediately. */
export const noop = Layer.succeed(Service, Service.of({ flush: Effect.void }))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { mkdtemp } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
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/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEngine } from "@opencode-ai/core/session-engine"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const testLLM = TestLLM.layer()
// The environment's engine graph compiles the scripted client from the same
// Layer references the application root uses, so the shared MemoMap yields
// one TestLLM instance for both pushes and drains.
const scriptedClient = TestLLM.clientLayer.pipe(Layer.provide(testLLM))
const shared: LayerNode.Replacements = [
[Bus.node, Bus.configured({ persist: true })],
[LayerNodePlatform.llmClient, scriptedClient],
]
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionExecution.node,
Session.node,
SessionEngine.node,
]),
[...shared, [SessionEngine.node, SessionEngine.configured(shared)]],
).pipe(Layer.provideMerge(testLLM)),
)
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
},
)
const executions: string[] = []
const echo = {
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
options: { codemode: false as const },
execute: ({ text }: { text: string }) =>
Effect.sync(() => {
executions.push(text)
return { output: { text }, content: text }
}),
}
describe("SessionEngine", () => {
it.effect("drains a durable session against a values-constructed environment", () =>
Effect.gen(function* () {
executions.length = 0
const directory = AbsolutePath.make(
yield* Effect.promise(() => mkdtemp(path.join(tmpdir(), "session-engine-"))),
)
const envs = yield* SessionEngine.Service
const env = yield* envs.make({
directory,
model,
agents: (draft) => {
draft.update(Agent.defaultID, () => {})
draft.default(Agent.defaultID)
},
tools: (draft) => draft.add(echo),
})
const session = yield* env.session()
yield* TestLLM.push(TestLLM.tool("call_1", "echo", { text: "hello" }), TestLLM.text("done", "out_1"))
yield* session.prompt({ text: "use echo", resume: false })
const sessions = yield* Session.Service
yield* sessions.resume(session.id)
// The values tool executed inside the real drain.
expect(executions).toEqual(["hello"])
// The drain produced durable assistant history containing the scripted reply.
const messages = yield* sessions.messages({ sessionID: session.id })
const assistant = messages.filter((message) => message.type === "assistant")
expect(assistant.length).toBeGreaterThan(0)
const text = assistant
.flatMap((message) => message.content)
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
expect(text).toContain("done")
// Reconnect: the same call with the same ID adopts the existing Session.
const reconnected = yield* env.session({ id: session.id, title: "ignored on adoption" })
expect(reconnected.id).toBe(session.id)
expect((yield* sessions.messages({ sessionID: reconnected.id })).length).toBe(messages.length)
}),
)
})
@@ -12,6 +12,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEngineBindings } from "@opencode-ai/core/session/engine-bindings"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
@@ -1171,6 +1172,7 @@ function buildExecution(
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.provide(Layer.succeed(SessionStore.Service, store)),
Layer.provide(Layer.succeed(Job.Service, jobs)),
Layer.provide(SessionEngineBindings.layer),
Layer.provide(locations),
),
scope,