Practical reference for new and migrated Effect code in packages/opencode.
Use InstanceState (from src/effect/instance-state.ts) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a ScopedCache keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
Use makeRuntime (from src/effect/run-service.ts) to create a per-service ManagedRuntime that lazily initializes and shares layers via a global memoMap. Returns { runPromise, runFork, runCallback }.
Rule of thumb: if two open directories should not share one copy of the service, it needs InstanceState.
See instance-context.md for the phased plan to remove the legacy ALS / promise-backed Instance helper and move request / CLI / tool boundaries onto Effect-provided instance scope.
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
export * as Foo from "."
Rules:
export namespace Foo { ... }Effect.fn("Foo.method") for Effect methodsexport * as Foo from "." or "./foo") for the public namespace projectionmakeRuntime(...) facades unless a file is still intentionally in the older migration phaseLayer.fresh for normal per-directory isolation; use InstanceStateWhen a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the zod() helper from @/util/effect-zod:
import { zod } from "@/util/effect-zod"
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
See Auth.ZodInfo for the canonical example.
The InstanceState.make init callback receives a Scope, so you can use Effect.acquireRelease, Effect.addFinalizer, and Effect.forkScoped inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by ScopedCache. This makes it the right place for:
Bus.Service at the layer level, then use Stream + forkScoped inside the init closure. The fiber is automatically interrupted when the instance scope closes:const bus = yield * Bus.Service
const cache =
yield *
InstanceState.make<State>(
Effect.fn("Foo.state")(function* (ctx) {
// ... load state ...
yield* bus.subscribeAll().pipe(
Stream.runForEach((event) =>
Effect.sync(() => {
/* handle */
}),
),
Effect.forkScoped,
)
return {
/* state */
}
}),
)
Effect.acquireRelease or Effect.addFinalizer for resources that need teardown (native watchers, process handles, etc.):yield *
Effect.acquireRelease(
Effect.sync(() => nativeAddon.watch(dir)),
(watcher) => Effect.sync(() => watcher.close()),
)
Effect.forkScoped — the fiber is interrupted on disposal.InstanceState.get(cache) to trigger everything, and ScopedCache deduplicates automatically.The key insight: don't split init into a separate method with a started flag. Put everything in the InstanceState.make closure and let ScopedCache handle the run-once semantics.
Use Effect.cached when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
// Inside the layer — yield* to initialize the memo
let cached = yield * Effect.cached(loadExpensive())
const get = Effect.fn("Foo.get")(function* () {
return yield* cached // concurrent callers share the same fiber
})
// To invalidate: swap in a fresh memo
const invalidate = Effect.fn("Foo.invalidate")(function* () {
cached = yield* Effect.cached(loadExpensive())
})
Prefer Effect.cached over these patterns:
Fiber.Fiber | undefined with manual check-and-fork (e.g. file/index.ts ensure)Promise<void> task for deduplication (e.g. skill/index.ts ensure)let cached: X | undefined with check-and-load (races when two callers see undefined before either resolves)Effect.cached handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with yield* Effect.cached(...) — the old memo is discarded.
For loops or periodic work, use Effect.repeat or Effect.schedule with Effect.forkScoped in the layer definition.
In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs.
Prefer these first:
FileSystem.FileSystem instead of raw fs/promises for effectful file I/OChildProcessSpawner.ChildProcessSpawner with ChildProcess.make(...) instead of custom process wrappersHttpClient.HttpClient instead of raw fetchPath.Path instead of mixing path helpers into service code when you already need a path serviceConfig for effect-native configuration readsClock / DateTime for time reads inside effectsFor child process work in services, yield ChildProcessSpawner.ChildProcessSpawner in the layer and use ChildProcess.make(...).
Keep shelling-out code inside the service, not in callers.
Shared schema or model files can stay outside the service namespace when lower layers also depend on them.
That is fine for leaf files like schema.ts. Keep the service surface in the owning namespace.
Service-shape migrated (single namespace, traced methods, InstanceState where needed).
This checklist is only about the service shape migration. Many of these services still keep makeRuntime(...) plus async facade exports; that facade-removal phase is tracked separately in facades.md.
Account — account/index.tsAgent — agent/agent.tsAppFileSystem — filesystem/index.tsAuth — auth/index.ts (uses zod() helper for Schema→Zod interop)Bus — bus/index.tsCommand — command/index.tsConfig — config/config.tsDiscovery — skill/discovery.ts (dependency-only layer, no standalone runtime)File — file/index.tsFileWatcher — file/watcher.tsFormat — format/index.tsInstallation — installation/index.tsLSP — lsp/index.tsMCP — mcp/index.tsMcpAuth — mcp/auth.tsPermission — permission/index.tsPlugin — plugin/index.tsProject — project/project.tsProviderAuth — provider/auth.tsPty — pty/index.tsQuestion — question/index.tsSessionStatus — session/status.tsSkill — skill/index.tsSnapshot — snapshot/index.tsToolRegistry — tool/registry.tsTruncate — tool/truncate.tsVcs — project/vcs.ts[x] Worktree — worktree/index.ts
[x] Session — session/index.ts
[x] SessionProcessor — session/processor.ts
[x] SessionPrompt — session/prompt.ts
[x] SessionCompaction — session/compaction.ts
[x] SessionSummary — session/summary.ts
[x] SessionRevert — session/revert.ts
[x] Instruction — session/instruction.ts
[x] SystemPrompt — session/system.ts
[x] Provider — provider/provider.ts
[x] Storage — storage/storage.ts
[x] ShareNext — share/share-next.ts
[x] SessionTodo — session/todo.ts
Still open at the service-shape level:
SyncEvent — sync/index.ts (deferred pending sync with James)Workspace — control-plane/workspace.ts (deferred pending sync with James)Tool-specific migration guidance and checklist live in tools.md.
Some already-effectified areas still use raw Filesystem.* or Process.spawn in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
Filesystem.* → AppFileSystem.Service (yield in layer)config/config.ts — installDependencies() now uses AppFileSystemprovider/provider.ts — recent model state now reads via AppFileSystem.ServiceProcess.spawn → ChildProcessSpawner (yield in layer)format/formatter.ts — direct Process.spawn() checks removed (air, uv)lsp/server.ts — multiple Process.spawn() installs/download helpersutil/filesystem.ts is still used widely across src/, and raw fs / fs/promises imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from Filesystem.* to yielding AppFileSystem.Service where possible — this should happen naturally during each migration, not as a separate sweep.
Tool-specific filesystem cleanup notes live in tools.md.
util/lock.ts — reader-writer lock → Effect Semaphore/Permitutil/flock.ts — file-based distributed lock with heartbeat → Effect.repeat + addFinalizerutil/process.ts — child process spawn wrapper → return Effect instead of Promiseutil/lazy.ts — replace uses in Effect code with Effect.cached; keep for sync-only codeThis phase is no longer broadly open. There are 5 makeRuntime(...) call sites under src/, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in facades.md.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and AppRuntime (src/effect/app-runtime.ts) composes everything into one ManagedRuntime, we're removing them.
For each service, the migration is roughly:
grep -n "Namespace\.(methodA|methodB|...)" across src/ and test/. Skip the service file itself.Effect.tryPromise(() => Namespace.method(...)):
Layer.Layer<Self, never, ... | Namespace.Service>)const ns = yield* Namespace.ServiceEffect.tryPromise(() => Namespace.method(...)) with yield* ns.method(...) (or ns.method(...).pipe(Effect.orElseSucceed(...)) for the common fallback case)Layer.provide(Namespace.defaultLayer) to the caller's own defaultLayer chain.layer. Any test that composes Caller.layer (not defaultLayer) needs to also provide the newly-required service tag. The fastest fix is usually switching to Caller.defaultLayer since it now pulls in the new dependency.Namespace.method(...) directly get converted to full effectful style using testEffect(Namespace.defaultLayer) + it.live / it.effect + yield* svc.method(...). Don't wrap the test body in Effect.promise(async () => {...}) — do the whole thing in Effect.gen and use AppFileSystem.Service / tmpdirScoped / Effect.addFinalizer for what used to be raw fs / Bun.write / try/finally.grep shows zero callers, remove the export async function block AND the makeRuntime(...) line from the service namespace. Also remove the now-unused import { makeRuntime }.testEffect(layer) constructs the Storage (or whatever) service once and memoizes it. If a test then tries inner.pipe(Effect.provide(customStorage)) to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in Layer.fresh(...), which forces a new instance to be built instead of hitting the memoMap cache. This lets a single testEffect(...) serve both simple and per-test-customized cases.Effect.tryPromise → yield* drops the Promise layer. The old code was Effect.tryPromise(() => Storage.read(...)) — a tryPromise wrapper because the facade returned a Promise. The new code is yield* storage.read(...) directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for Effect.promise or Effect.tryPromise during migration; if you're using them on a service method call, you're doing it wrong..layer test callers break silently in the type checker. When you add a new R requirement to a service's .layer, any test that composes it raw (not defaultLayer) becomes under-specified. tsgo will flag this — the error looks like Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'. Usually the fix is to switch that composition to defaultLayer, or add Layer.provide(NewDep.defaultLayer) to the custom composition.fs, Bun.write, tmpdir. Convert these to AppFileSystem.Service calls inside Effect.gen, and use tmpdirScoped() instead of tmpdir() so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) }) keeps the migration tests clean.SessionStatus — migrated 2026-04-11. Replaced the last route and retry-policy callers with AppRuntime.runPromise(SessionStatus.Service.use(...)) and removed the makeRuntime(...) facade.ShareNext — migrated 2026-04-11. Swapped remaining async callers to AppRuntime.runPromise(ShareNext.Service.use(...)), removed the makeRuntime(...) facade, and kept instance bootstrap on the shared app runtime.SessionTodo — migrated 2026-04-10. Already matched the target service shape in session/todo.ts: single namespace, traced Effect methods, and no makeRuntime(...) facade remained; checklist updated to reflect the completed migration.Storage — migrated 2026-04-10. One production caller (Session.diff) and all storage.test.ts tests converted to effectful style. Facades and makeRuntime removed.SessionRunState — migrated 2026-04-11. Single caller in server/routes/instance/session.ts converted; facade removed.Account — migrated 2026-04-11. Callers in server/routes/instance/experimental.ts and cli/cmd/account.ts converted; facade removed.Instruction — migrated 2026-04-11. Test-only callers converted; facade removed.FileWatcher — migrated 2026-04-11. Callers in project/bootstrap.ts and test converted; facade removed.Question — migrated 2026-04-11. Callers in server/routes/instance/question.ts and test converted; facade removed.Truncate — migrated 2026-04-11. Caller in tool/tool.ts and test converted; facade removed.Route-handler migration guidance and checklist live in routes.md.