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.
Every service follows the same pattern — a single namespace with the service definition, layer, runPromise, and async facade functions:
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
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 })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
}
Rules:
service.ts / index.ts splitrunPromise goes inside the namespace (not exported unless tests need it)async function — no fn() wrappersEffect.fn("Namespace.method") for all Effect functions (for tracing)Layer.fresh — InstanceState handles per-directory isolationWhen 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:
Subscriptions: Yield 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 */
}
}),
)
Resource cleanup: Use 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()),
)
Background fibers: Use Effect.forkScoped — the fiber is interrupted on disposal.
Side effects at init: Config notification, event wiring, etc. all belong in the init closure. Callers just do 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.
Fully migrated (single namespace, InstanceState where needed, flattened facade):
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.tsFileTime — file/time.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] Provider — provider/provider.ts
[x] Storage — storage/storage.ts
Still open:
SessionTodo — session/todo.tsShareNext — share/share-next.tsSyncEvent — sync/index.tsWorkspace — control-plane/workspace.tsOnce individual tools are effectified, change Tool.Info (tool/tool.ts) so init and execute return Effect instead of Promise. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in Effect.promise() at the call site. Requires:
Tool.define() factory to work with EffectsSessionPrompt to yield* tool results instead of awaitingUntil the tool interface itself returns Effect, use this transitional pattern for migrated tools:
Tool.defineEffect(...) should yield* the services the tool depends on and close over them in the returned tool definition.Effect.runPromise(...) in the temporary async execute(...) implementation, and move the inner logic into Effect.fn(...) helpers instead of scattering runPromise islands through the tool body.ToolRegistry.defaultLayer so production callers resolve the same dependencies as tests.Tool tests should use the existing Effect helpers in packages/opencode/test/lib/effect.ts:
testEffect(...) / it.live(...) instead of creating fake local wrappers around effectful tools.const info = yield* ReadTool, const tool = yield* Effect.promise(() => info.init()).provideTmpdirInstance(...) or provideInstance(tmpdirScoped(...)) so instance-scoped services resolve exactly as they do in production.This keeps migrated tool tests aligned with the production service graph today, and makes the eventual Tool.Info → Effect cleanup mostly mechanical later.
Individual tools, ordered by value:
apply_patch.ts — HIGH: multi-step orchestration, error accumulation, Bus eventsbash.ts — HIGH: shell orchestration, quoting, timeout handling, output captureread.ts — HIGH: streaming I/O, readline, binary detection → FileSystem + Streamedit.ts — HIGH: multi-step diff/format/publish pipeline, FileWatcher lockgrep.ts — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handlingwrite.ts — MEDIUM: permission checks, diagnostics polling, Bus eventscodesearch.ts — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeoutwebfetch.ts — MEDIUM: fetch with UA retry, size limits → HttpClientwebsearch.ts — MEDIUM: MCP over HTTP → HttpClientbatch.ts — MEDIUM: parallel execution, per-call error recovery → Effect.alltask.ts — MEDIUM: task state managementls.ts — MEDIUM: bounded directory listing over ripgrep-backed traversalmultiedit.ts — MEDIUM: sequential edit orchestration over edit.tsglob.ts — LOW: simple async generatorlsp.ts — LOW: dispatch switch over LSP operationsquestion.ts — LOW: prompt wrapperskill.ts — LOW: skill tool adaptertodo.ts — LOW: todo persistence wrapperinvalid.ts — LOW: invalid-tool fallbackplan.ts — LOW: plan file operationsSome 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)file/index.ts — 1 remaining Filesystem.readText() call in untracked diff handlingconfig/config.ts — 5 remaining Filesystem.* calls in installDependencies()provider/provider.ts — 1 remaining Filesystem.readJson() call for recent model stateProcess.spawn → ChildProcessSpawner (yield in layer)format/formatter.ts — 2 remaining Process.spawn() checks (air, uv)lsp/server.ts — multiple Process.spawn() installs/download helpersutil/filesystem.ts (raw fs wrapper) is currently imported by 34 files. The effectified AppFileSystem service (filesystem/index.ts) is currently imported by 15 files. As services and tools are effectified, they should switch from Filesystem.* to yielding AppFileSystem.Service — this happens naturally during each migration, not as a separate effort.
Similarly, 21 files still import raw fs or fs/promises directly. These should migrate to AppFileSystem or Filesystem.* as they're touched.
Current raw fs users that will convert during tool migration:
tool/read.ts — fs.createReadStream, readlinetool/apply_patch.ts — fs/promisesfile/ripgrep.ts — fs/promisespatch/index.ts — fs, fs/promisesutil/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 code