Files
kilocode/packages/opencode/specs/effect-migration.md
T
Catriel Müller 90e86a5e3d OpenCode v1.3.4 (#8798)
* tweak: use theme tokens for debug bar surface

* chore: update nix node_modules hashes

* feat(tui): add heap snapshot functionality for TUI and server (#19028)

* ci

* change model for changelog

* release: v1.3.2

* fix(opencode): skip typechecking generated models snapshot (#19018)

* Revert "fix(app): more startup efficiency (#18985)"

This reverts commit 98b3340cee.

* Revert "fix(app): startup efficiency (#18854)"

This reverts commit 546748a461.

* effectify Worktree service (#18679)

* fix: increase operations-per-run to 1000 and pin stale action to v10.2.0

The stale-issues workflow was hitting the default 30 operations limit,
preventing it from processing all 2900+ issues/PRs. Increased to 1000
to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility.

* Add close-issues script and GitHub Action

- Create script/github/close-issues.ts to close stale issues after 60 days
- Add GitHub Action workflow to run daily at 2 AM
- Remove old stale-issues workflow to avoid conflicts

* Fix close-issues workflow permissions

- Add contents: read permission for checkout
- Use github.token instead of secrets.GITHUB_TOKEN

* Process issues sequentially to avoid rate limits

* Change issue close reason from not_planned to completed

* fix(opencode): avoid snapshotting files over 2MB (#19043)

* fix: provide merge context to beta conflict resolver (#19055)

* tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058)

* fix: beta resolver typecheck + build smoke check (#19060)

* fix: unblock beta conflict recovery (#19068)

* electron: add createDirectory to open directory picker (#19071)

* electron: remove file extension from electron-store wrapper (#19082)

* app: pre-warm project globalSync state when navigate project via keybind (#19088)

* fix(app): move message navigation off cmd+arrow (#18728)

* Reapply "fix(app): startup efficiency (#18854)"

This reverts commit a379eb3867.

* Reapply "fix(app): more startup efficiency (#18985)"

This reverts commit cbe1337f24.

* fix(app): hash inline script for csp

* Revert "fix(app): startup efficiency"

* Reapply "fix(app): startup efficiency"

This reverts commit 898456a25c.

* fix(app): opencode web server url

* chore(app): markdown playground in storyboard

* chore(app): markdown playground in storyboard

* feat(core): initial implementation of syncing (#17814)

* chore: generate

* chore: bump modelcontextprotocol/sdk to 1.27.1 (#19064)

* chore: storybook tweaks

* feat: restore git-backed review modes with effectful git service (#18900)

* chore: generate

* chore: update nix node_modules hashes

* chore: cleanup

* chore: remove dead code for todoread tool (#19128)

* chore: storybook tweaks

* fix(opencode): classify ZlibError from Bun fetch as retryable instead of unknown (#19104)

Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>

* fix(task): respect agent permission config for todowrite tool (#19125)

* fix(app): agent normalization (#19169)

* fix: Windows e2e stability (CrossSpawnSpawner, snapshot isolation, session race guards) (#19163)

* fix+refactor(mcp): lifecycle tests, cancelPending fix, Effect migration (#19042)

* effectify Bus service: migrate to Effect PubSub + InstanceState (#18579)

* file: use Effect.cached for scan deduplication (#19164)

* ignore: update disavowed list (#19184)

* skill: use Effect.cached for load deduplication (#19165)

* chore: generate

* fix: bump gitlab-ai-provider to 5.3.3 for DWS tool approval support (#19185)

* test: restore 5 workers on Windows e2e (#19188)

* fix(opencode): image paste on Windows Terminal 1.25+ with kitty keyboard (#17674)

* chore: update nix node_modules hashes

* wip: zen

* wip: zen

* go: do not respect disabled zen models

* fix: ensure enterprise url is set properly during auth flow (#19212)

* revert: roll back git-backed review modes (#19295)

* chore: generate

* tui: bypass local SSE event streaming in worker (#19183)

* feat: embed WebUI in binary with proxy flags (#19299)

Co-authored-by: BlankParticle <blankparticle@gmail.com>

* release: v1.3.3

* chore: generate

* changelog ci tweaks

* refactor(lsp): effectify LSP service with InstanceState (#19150)

* chore: generate

* feat: add gpt prompt so non codex gpt models have their own system prompt modeled after codex cli (#19220)

* feat(core): remove workspace server, WorkspaceContext, start work towards better routing (#19316)

* effectify Config service (#19139)

* chore: generate

* refactor(config): use cachedInvalidateWithTTL, bump effect to beta.37 (#19322)

* fix(mcp): close transport on failed/timed-out connections (#19200)

* fix(app): more startup perf (#19288)

* chore: generate

* chore: update nix node_modules hashes

* fix(app): don't bundle fonts (#19329)

* chore: generate

* fix(app): default shell tool to collapsed

* fix(app): remove fork session button

* fix(ui): reduce markdown jank while responses stream (#19304)

* fix: web ui bundle build on windows (#19337)

* refactor(effect): yield services instead of promise facades (#19325)

* chore: generate

* refactor(vcs): replace async git() with ChildProcessSpawner (#19361)

* fix(opencode): ignore generated models snapshot files (#19362)

* fix(ui): keep partial markdown readable while responses stream (#19403)

* chore: update nix node_modules hashes

* fix(app): persist queued followups across project switches (#19421)

* refactor(tool-registry): yield Config/Plugin services, use Effect.forEach (#19363)

* chore: generate

* tui plugins (#19347)

* chore: generate

* effectify Skill service internals (#19364)

* chore: update nix node_modules hashes

* effectify Plugin service internals (#19365)

* refactor(core): split out instance and route through workspaces (#19335)

* chore(app): more spacing controls

* fix(ui): make streamed markdown feel more continuous (#19404)

* fix(app): resize layout viewport when mobile keyboard appears (#15841)

* fix(desktop-electron): match dev dock icon inset on macOS (#19429)

* fix(app): default file tree to closed with minimum width (#19426)

* fix flaky plugin tests (no mock.module for bun) (#19445)

* tweak: add additional overflow error patterns (#19446)

* no theme override in dev (#19456)

* feat: AI SDK v6 support (#18433)

* refactor(session): effectify Session service (#19449)

* refactor(core): move more responsibility to workspace routing (#19455)

* chore: update nix node_modules hashes

* refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457)

* chore: generate

* Single target plugin entrypoints (#19467)

* refactor(session): effectify SessionCompaction service (#19459)

* feat(ci): use Azure Artifact Signing for Windows releases (#15201)

* fix(app): more startup efficiency (#19454)

* update effect to 4.0.0-beta.42 (#19484)

* chore: update nix node_modules hashes

* tweak: adjust bash tool description to increase cache hit rates between projects (#19487)

* refactor(session): move context into prompt footer (#19486)

* refactor(prompt): remove variant cycle display from footer (#19489)

* feat: add model variant selection dialog (#19488)

* fix: restore subagent footer and fix style guide violations (#19491)

* tweak(session): add top spacing and remove obsolete docs prompt

* upgrade opentui to 0.1.91 (#19440)

* refactor(file): use AppFileSystem instead of raw Filesystem (#19458)

* chore: generate

* chore: update nix node_modules hashes

* kv theme before default fallback (#19523)

* feat: open dialog for model variant selection instead of cycling (#19534)

* refactor(session): effectify session processor (#19485)

* feat: dialog variant menu and subagent improvements (#19537)

* use theme color for prompt placeholder (#19535)

* fix: update opencode-gitlab-auth to 2.0.1 (#19552)

* chore: update nix node_modules hashes

* prompt slot (#19563)

* fix: respect semver build identifiers for nix (#11915)

* fix: nix embedded web-ui support (#19561)

* ignore: kill todo (#19566)

* chore: update nix node_modules hashes

* wip: zen

* wip: zen

* zen: ZDR policy

* ci: cancel stale nix-hashes runs (#19571)

* release: v1.3.4

* refactor: kilo compat for v1.3.4

* fix: migration types

* refactor: upgrade kilo-gateway to ai sdk v6

* refactor: improve upstream merge script

* fix: fix some tests

* style(kilo-vscode): adjust indentation and formatting in parts-util and PopupSelector

Normalize boolean expression indentation in isCompletionResult to use
consistent 4-space alignment and reformat PopupSelectorProps generic
interface declaration to split the Omit type across multiple lines.

* docs(kilo-docs): update auto-generated source links

Remove outdated URLs and add new bug report issue link pointing to
anomalyco/opencode repository. Drop references to kilocode bug report
template and config precedence order docs, reducing total unique URLs
from 262 to 261.

* fix(kilo-ui): remove unused NerdFonts story and MONO_NERD_FONTS import

Drop the NerdFonts story from font.stories.tsx along with the unused
MONO_NERD_FONTS import, as the exported constant is no longer available
from the @opencode-ai/ui/font module.

* chore: update visual regression baselines

* fix(opencode): move Show conditional wrapper outside box in home onboarding

Relocate the Show component to wrap the box element instead of being
nested inside it, preventing the empty box from rendering when the
onboarding tip is not visible.

---------

Co-authored-by: Jay V <air@live.ca>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: James Long <longster@gmail.com>
Co-authored-by: André Cruz <acruz@cloudflare.com>
Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: BlankParticle <blankparticle@gmail.com>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Burak Yigit Kaya <byk@sentry.io>
Co-authored-by: Caleb Norton <n0603919@outlook.com>
Co-authored-by: Imanol Maiztegui <imanol.mzd@gmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-12 13:36:08 +02:00

12 KiB

Effect patterns

Practical reference for new and migrated Effect code in packages/opencode.

Choose scope

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 }.

  • Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
  • Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs

Rule of thumb: if two open directories should not share one copy of the service, it needs InstanceState.

Service shape

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:

  • Keep everything in one namespace, one file — no separate service.ts / index.ts split
  • runPromise goes inside the namespace (not exported unless tests need it)
  • Facade functions are plain async function — no fn() wrappers
  • Use Effect.fn("Namespace.method") for all Effect functions (for tracing)
  • No Layer.fresh — InstanceState handles per-directory isolation

Schema → Zod interop

When 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.

InstanceState init patterns

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.

Effect.cached for deduplication

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:

  • Storing a Fiber.Fiber | undefined with manual check-and-fork (e.g. file/index.ts ensure)
  • Storing a 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.

Scheduled Tasks

For loops or periodic work, use Effect.repeat or Effect.schedule with Effect.forkScoped in the layer definition.

Preferred Effect services

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/O
  • ChildProcessSpawner.ChildProcessSpawner with ChildProcess.make(...) instead of custom process wrappers
  • HttpClient.HttpClient instead of raw fetch
  • Path.Path instead of mixing path helpers into service code when you already need a path service
  • Config for effect-native configuration reads
  • Clock / DateTime for time reads inside effects

Child processes

For 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 leaf models

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.

Migration checklist

Fully migrated (single namespace, InstanceState where needed, flattened facade):

  • Accountaccount/index.ts
  • Agentagent/agent.ts
  • AppFileSystemfilesystem/index.ts
  • Authauth/index.ts (uses zod() helper for Schema→Zod interop)
  • Busbus/index.ts
  • Commandcommand/index.ts
  • Configconfig/config.ts
  • Discoveryskill/discovery.ts (dependency-only layer, no standalone runtime)
  • Filefile/index.ts
  • FileTimefile/time.ts
  • FileWatcherfile/watcher.ts
  • Formatformat/index.ts
  • Installationinstallation/index.ts
  • LSPlsp/index.ts
  • MCPmcp/index.ts
  • McpAuthmcp/auth.ts
  • Permissionpermission/index.ts
  • Pluginplugin/index.ts
  • Projectproject/project.ts
  • ProviderAuthprovider/auth.ts
  • Ptypty/index.ts
  • Questionquestion/index.ts
  • SessionStatussession/status.ts
  • Skillskill/index.ts
  • Snapshotsnapshot/index.ts
  • ToolRegistrytool/registry.ts
  • Truncatetool/truncate.ts
  • Vcsproject/vcs.ts
  • Worktreeworktree/index.ts

Still open and likely worth migrating:

  • Sessionsession/index.ts
  • SessionProcessor — blocked by AI SDK v6 PR (#18433)
  • SessionPrompt — blocked by AI SDK v6 PR (#18433)
  • SessionCompaction — blocked by AI SDK v6 PR (#18433)
  • Provider — blocked by AI SDK v6 PR (#18433)

Other services not yet migrated:

  • SessionSummarysession/summary.ts
  • SessionTodosession/todo.ts
  • SessionRevertsession/revert.ts
  • Instructionsession/instruction.ts
  • ShareNextshare/share-next.ts
  • SyncEventsync/index.ts
  • Storagestorage/storage.ts
  • Workspacecontrol-plane/workspace.ts

Tool interface → Effect

Once 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:

  1. Migrate each tool to return Effects
  2. Update Tool.define() factory to work with Effects
  3. Update SessionPrompt to yield* tool results instead of awaiting — blocked by AI SDK v6 PR (#18433)

Individual tools, ordered by value:

  • apply_patch.ts — HIGH: multi-step orchestration, error accumulation, Bus events
  • read.ts — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
  • edit.ts — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
  • grep.ts — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
  • write.ts — MEDIUM: permission checks, diagnostics polling, Bus events
  • codesearch.ts — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
  • webfetch.ts — MEDIUM: fetch with UA retry, size limits → HttpClient
  • websearch.ts — MEDIUM: MCP over HTTP → HttpClient
  • batch.ts — MEDIUM: parallel execution, per-call error recovery → Effect.all
  • task.ts — MEDIUM: task state management
  • glob.ts — LOW: simple async generator
  • lsp.ts — LOW: dispatch switch over LSP operations
  • skill.ts — LOW: skill tool adapter
  • plan.ts — LOW: plan file operations

Effect service adoption in already-migrated code

Some services are effectified but still use raw Filesystem.* or Process.spawn instead of the Effect equivalents. These are low-hanging fruit — the layers already exist, they just need the dependency swap.

Filesystem.*AppFileSystem.Service (yield in layer)

  • file/index.ts — 11 calls (the File service itself)
  • config/config.ts — 7 calls
  • auth/index.ts — 3 calls
  • skill/index.ts — 3 calls
  • file/time.ts — 1 call

Process.spawnChildProcessSpawner (yield in layer)

  • format/index.ts — 1 call

Filesystem consolidation

util/filesystem.ts (raw fs wrapper) is used by 64 files. The effectified AppFileSystem service (filesystem/index.ts) exists but only has 8 consumers. 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, 28 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, readline
  • tool/apply_patch.ts — fs/promises
  • tool/bash.ts — fs/promises
  • file/ripgrep.ts — fs/promises
  • storage/storage.ts — fs/promises
  • patch/index.ts — fs, fs/promises

Primitives & utilities

  • util/lock.ts — reader-writer lock → Effect Semaphore/Permit
  • util/flock.ts — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
  • util/process.ts — child process spawn wrapper → return Effect instead of Promise
  • util/lazy.ts — replace uses in Effect code with Effect.cached; keep for sync-only code