Effect-TS is a TypeScript library whose core type Effect<Success, Error, Requirements> encodes typed errors, dependency injection, and structured concurrency into the type system. OpenCode uses Effect for per-directory service isolation, resource lifecycle management, and testable dependency injection.
Key Effect concepts used in OpenCode:
ServiceMap.Service— type-safe service registry with automatic dependency trackingLayerMap— keyed map giving each key (directory) its own isolated set of servicesLayer— composable service factory with scope-based lifecycleEffect.addFinalizer— guaranteed cleanup when a scope exitsManagedRuntime— global runtime that hosts all service layers
There is no single RFC or design document. The rationale is reconstructed from PR descriptions by Kit Langton (@kitlangton), who authored all 20+ effectify PRs, and Tim Smart (@tim-smart), Founding Engineer at Effectful Technologies.
"Move question, permission, and provider auth onto a shared per-instance
LayerMapwithInstanceContextfor directory/project resolution. ReplaceInstanceStatewith closure-backed service state." — PR #17544
OpenCode serves multiple projects simultaneously. The old Instance.state() used mutable Maps keyed by directory. Effect's LayerMap gives each directory key a fresh, automatically-disposed set of services.
"Fix
InstanceState.get— was eagerly capturingInstance.directoryfrom ALS at the call site, freezing to whichever directory triggered first layer construction. Now usesEffect.suspendfor lazy per-evaluation reads." — PR #17511
Includes adversarial stress tests for ALS propagation through Effect fibers.
3. Hidden control-flow in the event bus
"A pub/sub system that blocks on all subscribers is a hidden control-flow dependency, not a bus. One slow listener shouldn't turn the bus into a synchronous pipeline." — PR #18173
"Hourly cleanup via
Effect.forkScoped+Schedule.spaced(replacesScheduler.register). Cleanup starts automatically whenManagedRuntimeis created (no moreTruncate.init()in bootstrap)." — PR #17957
"Tests use mock
HttpClient+ChildProcessSpawnerlayers instead ofglobalThis.fetch." — PR #18266
CLI / Server Entry Point
|
InstanceLifecycle.boot(directory)
|
InstanceALS.run(ctx, fn)
|
+-------------+-------------+
| |
Imperative Code Effect Runtime
(InstanceALS) (InstanceContext)
| |
InstanceALS.directory InstanceContext.directory
InstanceALS.worktree InstanceContext.worktree
InstanceALS.project InstanceContext.project
| |
Module-level LayerMap<string>
state Maps |
| +--------+--------+
registerDisposer() | | |
Layer Layer Layer
(dir A) (dir B) (dir C)
|
+----------+----------+
| | |
BusService EnvService FileService
ConfigService SkillService ...
(22 services total)
OpenCode maintains two parallel context systems:
-
InstanceALS (
src/project/instance-als.ts) — Node.jsAsyncLocalStorageprovidingdirectory,worktree, andprojectto imperative code. WrapsAsyncLocalStoragevia aContext.create()utility (src/util/context.ts). -
InstanceContext (
src/effect/instance-context.ts) — an EffectServiceMap.Servicecarrying the same three values through the Effect type system. Required by all 22 Effect services.
Both are populated at boot time and carry identical values. InstanceALS serves non-Effect code (tools, commands, prompt pipeline). InstanceContext serves Effect service layers.
Every Effect service follows this pattern:
// 1. Module-level state map (keyed by directory)
const states = new Map<string, { subscriptions: Map<string, Callback[]> }>()
// 2. Imperative accessor
function state(directory: string) {
let s = states.get(directory)
if (!s) { s = { subscriptions: new Map() }; states.set(directory, s) }
return s
}
// 3. Effect service definition
export class BusService extends ServiceMap.Service<BusService, BusService.Service>()("@opencode/Bus") {
static readonly layer = Layer.effect(BusService, Effect.gen(function* () {
const ctx = yield* InstanceContext // Get per-directory context
const dir = ctx.directory
state(dir) // Initialize state
yield* Effect.addFinalizer(() => // Cleanup on scope exit
Effect.sync(() => { states.delete(dir) })
)
return BusService.of({ // Return service interface
publish: Bus.publish,
subscribe: Bus.subscribe,
})
}))
}| Service | File | Scope |
|---|---|---|
| InstanceContext | src/effect/instance-context.ts |
Per-directory |
| BusService | src/bus/index.ts |
Per-directory |
| EnvService | src/env/index.ts |
Per-directory |
| FileService | src/file/index.ts |
Per-directory |
| FileTimeService | src/file/time.ts |
Per-directory |
| FileWatcherService | src/file/watcher.ts |
Per-directory |
| FormatService | src/format/index.ts |
Per-directory |
| PermissionService | src/permission/service.ts |
Per-directory |
| ProviderAuthService | src/provider/auth-service.ts |
Per-directory |
| VcsService | src/project/vcs.ts |
Per-directory |
| QuestionService | src/question/service.ts |
Per-directory |
| InstructionService | src/session/instruction.ts |
Per-directory |
| SessionStatusService | src/session/status.ts |
Per-directory |
| SkillService | src/skill/skill.ts |
Per-directory |
| SnapshotService | src/snapshot/index.ts |
Per-directory |
| ConfigService | src/effect/service-layers.ts |
Per-directory |
| PluginService | src/effect/service-layers.ts |
Per-directory |
| ToolRegistryService | src/effect/service-layers.ts |
Per-directory |
| AgentService | src/effect/service-layers.ts |
Per-directory |
| TuiConfigService | src/config/tui-service.ts |
Per-directory |
| AccountService | src/account/service.ts |
Global |
| AuthService | src/auth/service.ts |
Global |
Note: Service definitions for Config, Plugin, ToolRegistry, and Agent are in service-layers.ts rather than their respective modules to avoid changing Bun's module evaluation order and breaking existing circular dependency chains.
Non-Effect modules maintain per-directory state via module-level Maps with cleanup:
const agentStates = new Map<string, Promise<Record<string, Agent.Info>>>()
registerDisposer(async (directory) => {
agentStates.delete(directory)
})registerDisposer() (src/effect/instance-registry.ts) registers a callback invoked when InstanceLifecycle.dispose(directory) is called, ensuring no memory leaks.
src/effect/instances.ts creates fresh service layers per directory:
function lookup(key: string) {
const shape = contextByDirectory.get(key) ?? InstanceALS.current
const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(shape))
return Layer.mergeAll(
Layer.fresh(BusService.layer),
Layer.fresh(EnvService.layer),
Layer.fresh(QuestionService.layer),
// ... 11 more services
).pipe(Layer.provide(ctx))
}Layer.fresh() ensures each directory gets its own service instances with no shared state.
boot(directory)
|
InstanceALS.run(ctx) # Enter ALS context
|
InstanceBootstrap() # Initialize plugins, LSP, watchers
|
runPromiseInstance(effect, dir) # Run Effect computations in context
|
LayerMap.get(directory) # Create/retrieve per-directory services
|
[application runs]
|
dispose(directory)
|
disposeInstance(dir) # Call all registerDisposer callbacks
LayerMap.invalidate(dir) # Tear down Effect service layers
Effect.addFinalizer callbacks # Clean up Effect-managed resources
cache.delete(dir) # Remove boot cache entry
emit(InstanceDisposed) # Notify listeners
| Date | PR | Description |
|---|---|---|
| 2026-03-11 | #17072 | Tighten effect-based account flows |
| 2026-03-12 | #17212 | effectify AuthService |
| 2026-03-12 | #17227 | effectify ProviderAuthService |
| 2026-03-12 | #17238 | Effect logger compatibility layer |
| 2026-03-13 | #17273 | Scaffold effect-to-zod bridge |
| 2026-03-14 | #17432 | effectify QuestionService |
| 2026-03-14 | #17511 | effectify PermissionNext + fix ALS propagation bug |
| 2026-03-15 | #17544 | Move scoped services to LayerMap |
| 2026-03-16 | #17827--#17849 | effectify FileWatcher, Vcs, FileTime, Format, File, Skill (6 PRs) |
| 2026-03-17 | #17957 | effectify Truncate, delete Scheduler |
| 2026-03-18 | #17878 | effectify Snapshot |
| 2026-03-18 | #18093 | Unify all service namespaces |
| 2026-03-18 | #18158 | Upgrade to effect 4.0.0-beta.35 |
| 2026-03-19 | #18266 | effectify Installation (mock layers for tests) |
| 2026-03-19 | #18173 | Migrate Bus to Effect PubSub |
| 2026-03-19 | #18282 | Skill: forkScoped + Fiber.join |
| 2026-03-20 | #18336 | Tim Smart: refactor effect runtime |
| 2026-03-21 | #18483 | Move state into InstanceState, flatten facades |
Still open: #18269 (ToolRegistry), #18270 (Plugin), #18271 (Command), #18319 (Pty), #18321 (LSP), #18323 (Worktree), #18313 (SessionStatus)
| Pros | Cons |
|---|---|
| Per-directory isolation is type-safe and guaranteed | Large dependency: effect package adds ~500KB |
addFinalizer prevents resource leaks |
Learning curve: Effect's generator syntax (yield*) is unfamiliar to most TS developers |
| LayerMap creates fresh services per key automatically | Dual context (ALS + Effect) is conceptual overhead |
| Testable via mock layers without monkey-patching | Service definitions split across files to avoid circular imports |
| Typed errors surface issues at compile time | Debug stack traces are longer due to Effect's fiber runtime |
Layer.fresh() prevents cross-directory contamination |
Migration is incremental — some modules still use raw state maps |
Frankencode completed the Effect-ification ahead of upstream in several areas:
| Stage | Commit | What changed |
|---|---|---|
| B1 | PR #20 | 16 modules converted from Instance.state() to module-level state maps with registerDisposer |
| B2-B8 | Branch commits | Tool layer migration, leaf state-map modules, formatter params, LSP module, session helpers, worktree + config |
| B9 | Branch commits | Server + CLI entry points capture Instance values at handler top |
| B10a-g | Branch commits | Effect runtime, service-layers, prompt construction, ALS fallback removal, InstanceLifecycle module |
-
src/project/instance.tsdeleted — upstream still hasinstance-state.tsusingScopedCache; we deleted the entire Instance module and split into InstanceALS + InstanceLifecycle + InstanceContext. -
0 ALS fallbacks — we eliminated all 59
?? InstanceALS.xfallback patterns from src/. Upstream still has ~36 deferred. -
Test shim —
test/fixture/instance-shim.tsprovides backward-compatibleInstance.provide()API for 58 test files, avoiding mechanical rewriting. -
81 TUI component tests — added as part of the Effect-ification branch to verify the refactored code.
- FRANKENCODE.md — complete list of Frankencode vs OpenCode changes
- API_PROVIDERS.md — provider architecture (ProviderAuthService is an Effect service)
- context-editing.md — context editing tools (use Effect services for state management)
- AGENT_CLIENT_PROTOCOL.md — ACP protocol (sessions boot via InstanceLifecycle)
- schema.md — database schema (Drizzle ORM, managed by Effect service layers)