feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture

Forked from OpenCode v1.17.4 with multi-agent system:
- 5 agents: aircoding, scheduler, worker, architect, reviewer
- Deterministic DAG scheduling engine (coordinator_tick)
- Tool whitelists as hard enforcement
- AirCoding validation plugin
- System prompt injection for routing
- V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md
- Design documents in docs/
This commit is contained in:
airlongdian
2026-06-13 21:41:54 +08:00
commit af3016fe27
5757 changed files with 1170017 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
# Error Boundaries Plan
Plan for removing `NamedError` as connective tissue while keeping public
wire contracts stable.
## Desired Shape
```text
Domain/service error
Schema.TaggedErrorClass
- catchable with catchTag / catchTags
- appears in service method error type
- no HTTP status
- no toObject()
HTTP public error
Schema.ErrorClass / TaggedErrorClass with httpApiStatus
- endpoint-declared public contract
- owns legacy { name, data } only when that is the SDK wire shape
CLI/user rendering
FormatError and small format helpers
- converts domain errors to text
- preserves useful structured fields
Session/model-visible error
first-class session/message error schema or helper
- owns { name, data } event/message shape
- not a service error class
```
The important rule: a service error should not also be the HTTP body, CLI
formatter, and session event body. Each seam adapts the error into the
shape it owns.
## Concrete Example: Provider Model Not Found
Before:
```ts
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
```
Problems:
- Throwing it inside `Effect.fn` made it behave like a defect unless a
compatibility bridge caught it.
- HTTP middleware knew that this one domain error should be a `400`.
- Callers read `.data.*`, which couples them to the legacy `{ name, data }`
wire shape.
After:
```ts
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
}
```
Boundary adapters:
```text
CLI
└─ FormatError sees _tag ProviderModelNotFoundError -> nice text
Session prompt
└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape
HTTP route
└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it
HTTP middleware
└─ no Provider.ModelNotFoundError knowledge
```
## Refining Known Promise Failures
Use `EffectPromise.refineRejection(...)` when a Promise boundary can reject
with many unknown values, but only one or two rejection classes are expected
domain failures. Unknown rejections stay defects; the helper maps only known
rejection shapes to typed errors.
```ts
const language =
yield *
EffectPromise.refineRejection(
async () => loadFromProvider(),
(cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
)
```
Use this when the Promise can genuinely reject and most rejection values are
still defects for the current module. Use `Effect.tryPromise({ try, catch })`
when every rejection should become the same expected error type. Use
`Effect.promise(...)` only when rejection means a defect and you do not need
to refine known rejection classes.
## Helper Modules We Probably Want
Add helpers only when repeated call sites prove the seam is real.
### HTTP API Errors
Likely location: `src/server/routes/instance/httpapi/errors.ts`.
Purpose:
- construct public HTTP error bodies
- preserve legacy `{ name, data }` where needed
- attach `httpApiStatus`
Good helpers:
```ts
notFound(message)
badRequest(message)
unknown()
```
Avoid:
```ts
mapAnyDomainError(error)
```
That recreates the giant middleware mapper problem.
### Session / Message Error Wire Helpers
Likely location: near `src/session/message-error.ts` or a new narrow
module such as `src/session/event-error.ts`.
Purpose:
- construct the `{ name, data }` shape used by `Session.Event.Error` and
assistant message errors
- replace `new NamedError.Unknown(...).toObject()` call sites
- keep model-visible error bodies separate from service/domain errors
Good helpers:
```ts
unknown(message)
agentNotFound(agent, available)
commandNotFound(command, available)
modelNotFound(error: Provider.ModelNotFoundError)
```
### CLI Formatters
Likely location: `src/cli/error.ts` until repetition demands domain-local
format helpers.
Purpose:
- produce human-readable terminal messages from typed errors
- support old `{ name, data }` shapes only while compatibility is needed
## Migration Queue
### Remove Domain Knowledge From HTTP Middleware
- [x] Storage not found no longer maps through defect fallback.
- [x] Worktree expected errors moved to typed errors.
- [x] Provider auth expected errors moved to typed errors.
- [x] Provider model not found no longer needs an HTTP middleware status
special case.
- [ ] Convert `Session.BusyError` and map it at route boundaries.
- [ ] Delete the broad `NamedError` middleware branch once no route relies
on defect-wrapped legacy domain errors.
- [ ] Keep one final unknown-defect fallback that logs `Cause.pretty(cause)`
and returns a safe `500` body.
### Remaining `NamedError.create(...)` Service Errors
These should become `Schema.TaggedErrorClass` when touched:
- [ ] `src/provider/provider.ts``ProviderInitError`.
- [ ] `src/storage/db.ts` — database `NotFoundError`.
- [ ] `src/mcp/index.ts``MCPFailed`.
- [ ] `src/skill/index.ts``SkillInvalidError`,
`SkillNameMismatchError`.
- [ ] `src/lsp/client.ts``LSPInitializeError`.
- [ ] `src/ide/index.ts` — install errors.
- [ ] `src/config/error.ts`, `src/config/config.ts`,
`src/config/markdown.ts` — config errors. These already render well
in the CLI, so migrate carefully and preserve diagnostics.
### Session / Message Wire Errors
These are not ordinary service errors. They mostly build `{ name, data }`
objects for model-visible/session-visible output.
- [ ] Add a first-class session/message error wire helper.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in
`src/session/prompt.ts`.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in config/skill/plugin
session event publishing.
- [ ] Move `src/session/message-error.ts` and `src/session/message-v2.ts`
away from `NamedError.create(...)` once the wire helper exists.
- [ ] Update retry/message tests to assert the wire schema/helper output,
not `NamedError` instances.
### CLI Rendering
- [x] Tagged config errors render with useful diagnostics.
- [x] Provider model not found renders from both old `{ name, data }` and
new `_tag` shapes.
- [ ] Add typed render cases as more `NamedError.create(...)` domains move
to `Schema.TaggedErrorClass`.
- [ ] Eventually remove old-shape compatibility branches when no callers can
produce them.
## PR Checklist
For each migrated error:
- [ ] Domain error is `Schema.TaggedErrorClass`.
- [ ] Service method exposes the typed error in its error channel.
- [ ] No service error has `toObject()` just for compatibility.
- [ ] CLI, HTTP, and session/message adapters each own their output shape.
- [ ] HTTP middleware gets smaller or stays unchanged.
- [ ] Focused tests cover the domain error and any public rendering/wire
shape touched by the PR.

View File

@@ -0,0 +1,207 @@
# Typed Error Migration
This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
[`todo.md`](./todo.md). It is the current reference for expected failures,
typed service errors, and HTTP error boundaries.
For the migration architecture and queue, see
[`error-boundaries-plan.md`](./error-boundaries-plan.md).
## Goal
- Expected service failures live on the Effect error channel.
- Service interfaces expose those failures in their return types.
- Domain errors are authored with `Schema.TaggedErrorClass`.
- `Effect.die(...)` is reserved for defects: bugs, impossible states,
violated invariants, and final unknown-boundary fallbacks.
- HTTP status codes and public wire bodies are handled at HTTP route
boundaries, not inside service modules.
- User-facing boundaries render useful structured error details instead of
opaque `Error: SomeName` strings.
## Service Error Shape
```ts
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
sessionID: SessionID,
message: Schema.String,
}) {}
export type Error = Storage.Error | SessionBusyError
export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}
```
Rules:
- Use `Schema.TaggedErrorClass` for expected domain failures.
- Export a domain-level `Error` union from each service module.
- Put expected errors in service method signatures.
- Use `yield* new DomainError(...)` for direct early failures in
`Effect.gen` / `Effect.fn`.
- Use `Schema.Defect` for unknown cause fields when preserving the cause is
useful for logs or callers.
- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
`Effect.catchTag`, and `Effect.catchTags` to translate external
failures into domain errors.
- Do not use `throw`, `Effect.die(...)`, or `catchDefect` for expected
user, IO, validation, missing-resource, auth, provider, worktree, or
busy-state failures.
## HTTP Boundary Shape
Service modules stay transport-agnostic. They should not import HTTP
status codes, `HttpApiError`, `HttpServerResponse`, or route-specific
error schemas.
HTTP handlers translate service errors into public endpoint errors:
```ts
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
return yield* session
.get(ctx.params.sessionID)
.pipe(Effect.catchTag("StorageNotFoundError", () => notFound("Session not found")))
})
```
Endpoint definitions declare which public errors can be emitted. Public
HTTP error schemas carry their response status with `httpApiStatus` or the
equivalent HttpApi schema annotation.
Effect's own HttpApi examples follow this pattern:
```ts
export class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class Authorization extends HttpApiMiddleware.Service<
Authorization,
{
provides: CurrentUser
}
>()("app/Authorization", {
security: { bearer: HttpApiSecurity.bearer },
error: Unauthorized,
}) {}
```
Endpoint-level errors use the same idea:
```ts
export class ConfigApiError extends Schema.ErrorClass<ConfigApiError>("ConfigApiError")(
{
name: Schema.Union(Schema.Literal("ConfigInvalidError"), Schema.Literal("ConfigJsonError")),
data: Schema.Struct({ message: Schema.optional(Schema.String), path: Schema.String }),
},
{ httpApiStatus: 400 },
) {}
HttpApiEndpoint.get("get", "/config", {
success: Config.Info,
error: ConfigApiError,
})
```
The service error and HTTP error may be the same class only when the wire
shape is intentionally public. Use separate HTTP error schemas when the
service error contains internals, low-level causes, retry hints, or data
that should not be exposed to API clients.
Do not map every domain error into one universal HTTP error class. Prefer a
small public error vocabulary by route group: shared shapes like
`ApiNotFoundError`, route-specific shapes like `ConfigApiError`, and built-in
empty `HttpApiError.*` only when an empty/no-content body is the intended SDK
contract.
## Mapping Guidance
- Keep one-off translations inline in the handler.
- Extract tiny shared helpers when the same translation repeats across a
route group.
- Do not create one giant `unknown -> status` mapper.
- Do not grow generic HTTP middleware into a registry of domain errors.
- Preserve existing public `{ name, data }` bodies until a deliberate
breaking API change.
- Use built-in `HttpApiError.*` only when its generated body and SDK
surface are intentionally the public contract.
- Prefer `Schema.ErrorClass` for public HTTP error bodies whose wire shape is
not the same as the internal domain error shape.
- Prefer `Schema.TaggedErrorClass` for service/domain errors and middleware
errors that are naturally tagged by `_tag`.
- If preserving a legacy `{ name, data }` body, model that shape explicitly in
the public API error schema instead of relying on `NamedError.toObject()` in
generic middleware.
## User-Facing Rendering
HTTP serialization and user rendering are separate boundaries. The server
should send structured public errors; CLI and TUI code should format those
structures through one shared formatter.
For SDK calls using `{ throwOnError: true }`, the generated client may wrap the
decoded response body in an `Error`. The original body should remain available
under `error.cause.body`; `FormatError` is the right place to unwrap and render
that body. TUI aggregation helpers should call `FormatError` first, then fall
back to generic `Error.message` / string rendering.
When several parallel startup requests fail from the same underlying issue,
group identical rendered messages and list the affected request names once.
For example:
```text
Configuration is invalid at /path/to/opencode.json
↳ Expected object, got "not-object" provider.bad.options
Affected startup requests: config.providers, provider.list, app.agents, config.get
```
## Middleware Guidance
HTTP middleware should be cross-cutting: auth, context, schema decode
formatting, routing, and final unknown-defect fallback.
The current compatibility middleware still knows about some legacy domain
errors. As route groups declare expected errors and handlers map them, that
middleware should shrink. It should not gain new name checks.
Unknown `500` responses should log full details server-side with
`Cause.pretty(cause)` and return a safe public body.
The config startup regression in #27056 is the failure mode this rule is meant
to avoid: a user-authored invalid `opencode.json` crossed the HttpApi boundary
as a defect, so middleware replaced a useful `ConfigInvalidError` with a safe
generic `UnknownError`. The compatibility fix is to preserve config parse and
validation errors as client-visible `400`s. The target architecture is better:
config loading should fail on the typed error channel, config HTTP handlers
should map those errors to declared `ConfigApiError` responses, and the generic
middleware should never see them.
## Migration Order
Prefer small vertical slices:
1. Fix rendering at one user-visible boundary.
2. Convert one service domain to `Schema.TaggedErrorClass` errors.
3. Map those errors at the affected HTTP handlers.
4. Remove the corresponding name-based middleware branch if possible.
5. Add or update focused tests for both service error tags and HTTP wire
bodies.
Good early domains are storage not-found, worktree errors, and provider
auth validation errors because they currently drive HTTP behavior.
Config parse and validation errors are also a good early slice because they
are startup-blocking and must be rendered clearly in both CLI and TUI flows.
## Checklist For A PR
- [ ] Expected failures are typed errors, not defects.
- [ ] Service method signatures expose the expected error union.
- [ ] HTTP handlers translate domain errors at the boundary.
- [ ] Public HTTP error bodies preserve existing wire contracts.
- [ ] Generic middleware gets smaller or stays unchanged.
- [ ] Focused tests cover the service error and any public HTTP response.

View File

@@ -0,0 +1,218 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
Current status on this branch:
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
## Priority hotspots
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
## Completed Batches
Low-risk batch, all merged:
1. `src/pty/index.ts`
2. `src/skill/index.ts`
3. `src/project/vcs.ts`
4. `src/tool/registry.ts`
5. `src/auth/index.ts`
Caller-heavy batch, all merged:
1. `src/config/config.ts`
2. `src/provider/provider.ts`
3. `../core/src/filesystem.ts`
4. `src/lsp/index.ts`
5. `src/mcp/index.ts`
Shared pattern:
- one service file still exports `makeRuntime(...)` + async facades
- one or two route or CLI entrypoints call those facades directly
- tests call the facade directly and need to switch to `yield* svc.method(...)`
- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import
## Done means
For each service in the low-risk batch, the work is complete only when all of these are true:
1. all production callers stop using `Namespace.method(...)` facade calls
2. all direct test callers stop using the facade and instead yield the service from context
3. the service file no longer has `makeRuntime(...)`
4. the service file no longer exports runtime-backed facade helpers
5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names
## Caller templates
### Route handlers
Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it.
```ts
const value = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
)
```
If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`.
### Plain async CLI or script entrypoints
If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work.
```ts
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
const skill = yield* Skill.Service
yield* auth.set(key, info)
return yield* skill.all()
}),
)
```
Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow.
This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file.
### Bootstrap or fire-and-forget startup code
If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file.
```ts
void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
```
Do not reintroduce a dedicated runtime in the service just for bootstrap.
### Tests
Convert facade tests to full effect style.
```ts
it.effect("does the thing", () =>
Effect.gen(function* () {
const svc = yield* Pty.Service
const info = yield* svc.create({ command: "cat", title: "a" })
yield* svc.remove(info.id)
}).pipe(Effect.provide(Pty.defaultLayer)),
)
```
If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body.
Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need.
If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer:
```ts
const infra = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
```
Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`.
## Questions already answered
### Do we need to effectify the whole caller first?
No.
- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))`
- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))`
- bootstrap: use the existing bootstrap runtime
Facade removal does not require a bigger refactor than that.
### Should tests keep calling the namespace from async test bodies?
No. Convert them now.
The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests.
### Should we keep `runPromise` exported for convenience?
No. For this batch the goal is to delete the service-local runtime entirely.
### What if a route has websocket callbacks or nested async handlers?
Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code.
### Should we use one `runPromise` per service call?
No.
Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block.
Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations.
### Should we wrap a single service expression in `Effect.gen(...)`?
Usually no.
Prefer the direct form when there is only one expression:
```ts
await Effect.runPromise(FileSystem.Service.use((svc) => svc.read({ path })))
```
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
## Learnings
These were the recurring mistakes and useful corrections from the first two batches:
1. Tests should usually provide the specific service layer, not `AppRuntime`.
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
3. Location-scoped services may need both the service layer and the right location fixture. `FileSystem` tests, for example, provide `Location.Service` plus `FileSystem.locationLayer`.
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Remaining work
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
## Checklist
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
- [x] `../core/src/filesystem.ts` (`FileSystem`) - legacy opencode service removed
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
## Excluded `makeRuntime(...)` sites
- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target.
- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade.

View File

@@ -0,0 +1,247 @@
# Effect Guide
How we write Effect code in `packages/opencode`. The companion roadmap is
[`todo.md`](./todo.md).
This guide describes the preferred shape for new work and migrations. If a
legacy file differs, migrate it only when it is already in scope.
## Service Shape
Use one module per service: flat top-level exports, traced Effect methods,
explicit layers, and a self-reexport at the bottom.
```ts
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 yield* loadFoo(s, id)
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.defaultLayer))
export * as Foo from "./foo"
```
Rules:
- Do not use `export namespace Foo { ... }`.
- Use `Effect.fn("Foo.method")` for public service methods.
- Use `Effect.fnUntraced` for small internal helpers that do not need a
span.
- Keep helpers as non-exported top-level declarations in the same file.
- Self-reexport with `export * as Foo from "."` for `index.ts`, otherwise
`export * as Foo from "./foo"`.
- In `src/config`, keep the existing top-of-file self-export pattern.
## Runtime Boundaries
Most code should run through [`AppRuntime`](../../src/effect/app-runtime.ts).
It hosts `AppLayer`, shares the global `memoMap`, and restores the current
instance/workspace refs when crossing from non-Effect code.
Use `AppRuntime.runPromise(effect)` at app boundaries such as CLI commands,
HTTP handlers, or plain async adapters.
`makeRuntime(...)` still exists for a few intentional service-local
boundaries and migration leftovers. Do not add a new service-local runtime
unless the service truly cannot live in `AppLayer`.
## Runtime Flags
Read opencode runtime flags through
[`RuntimeFlags.Service`](../../src/effect/runtime-flags.ts), not through
mutable `Flag` or late `process.env` reads.
Tests should vary behavior with explicit layer variants:
```ts
const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalReferences: true }))))
```
Do not mutate `process.env` or `Flag` after services/layers are built.
## Per-Instance State
Use [`InstanceState`](../../src/effect/instance-state.ts) when two open
directories should not share one copy of a service's state. It is backed by
a `ScopedCache`, keyed by directory, and disposed automatically when an
instance is unloaded.
Put subscriptions, finalizers, and scoped background work inside the
`InstanceState.make(...)` initializer:
```ts
const cache =
yield *
InstanceState.make<State>(
Effect.fn("Foo.state")(function* () {
const bus = yield* Bus.Service
yield* bus.subscribeAll().pipe(
Stream.runForEach((event) => handleEvent(event)),
Effect.forkScoped,
)
yield* Effect.acquireRelease(openResource, closeResource)
return yield* loadInitialState()
}),
)
```
Do not add separate `started` flags on top of `InstanceState`. Let
`ScopedCache` handle run-once and deduplication.
To make `init()` non-blocking, fork at the caller/bootstrap boundary. Do
not fork inside `InstanceState.make(...)` just to return early with
partially initialized state.
## Errors
Expected domain failures belong on the Effect error channel. Defects are
for bugs, impossible states, and final unknown-boundary fallbacks.
```ts
export class SessionBusyError extends Schema.TaggedErrorClass<SessionBusyError>()("SessionBusyError", {
sessionID: SessionID,
message: Schema.String,
}) {}
export type Error = Storage.Error | SessionBusyError
export interface Interface {
readonly get: (id: SessionID) => Effect.Effect<Info, Error>
}
```
Rules:
- Use `Schema.TaggedErrorClass` for new expected domain errors.
- Export a domain-level `Error` union from service modules.
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` for
direct expected failures.
- Use `Schema.Defect` for unknown cause fields.
- Use `Effect.try(...)`, `Effect.tryPromise(...)`, `Effect.mapError`,
`Effect.catchTag`, and `Effect.catchTags` to translate external
failures into domain errors.
- Do not use `Effect.die(...)` for user, IO, validation, missing-resource,
auth, provider, or busy-state failures.
## HTTP Error Boundaries
Service modules stay HTTP-agnostic. They should not import HTTP status
codes, `HttpApiError`, `HttpServerResponse`, or route-specific error
schemas.
HTTP handlers translate service errors into endpoint-declared public error
schemas. Keep mappings inline when they are one-off; extract tiny shared
helpers only when the same translation repeats.
Do not turn generic middleware into a registry of domain errors. Middleware
should handle cross-cutting concerns and the final unknown-defect fallback.
Preserve legacy public wire shapes, such as `{ name, data }`, until a
deliberate breaking API change.
## Schemas
Use Effect Schema as the source of truth.
- Use `Schema.Class` for exported data objects with a clear identity.
- Use `Schema.Struct` for local shapes and simple nested objects.
- Use `Schema.brand` for single-value IDs.
- Reuse named refinements instead of re-spelling constraints.
- Prefer narrow boundary helpers over generic Schema-to-Zod bridges.
Intentional boundaries:
- Public plugin tools still expose Zod through `tool.schema = z`.
- Tool parameter JSON Schema is generated through tool-specific helpers.
- Public config and TUI schemas are generated through the schema script.
## Preferred Services
In effectified code, yield existing services instead of dropping to ad hoc
platform APIs.
- Use `FSUtil.Service` instead of raw `fs/promises` for app file IO.
- Use `AppProcess.Service` instead of direct `ChildProcessSpawner.spawn` or
legacy process helpers.
- Use `HttpClient.HttpClient` instead of raw `fetch` inside Effect code.
- Use `Path.Path`, `Config`, `Clock`, and `DateTime` when already inside
Effect.
- Use `Effect.callback` for callback-based APIs.
- Use `Effect.void` instead of `Effect.succeed(undefined)`.
- Use `Effect.cached` when concurrent callers should share one in-flight
computation.
For background loops, use `Effect.repeat` or `Effect.schedule` with
`Effect.forkScoped` in the owning layer/state scope.
## Promise And ALS Bridges
[`EffectBridge`](../../src/effect/bridge.ts) is the sanctioned helper for
Promise/callback interop that needs to preserve instance/workspace context.
It preserves explicit `InstanceRef` / `WorkspaceRef` context for effects run
through the bridge. Plain JS callbacks that need instance data should receive
that data explicitly.
## Testing
Detailed test migration rules live in
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
Core pattern:
```ts
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.instance("does the thing", () =>
Effect.gen(function* () {
const svc = yield* MyService.Service
expect(yield* svc.run()).toEqual("ok")
}),
)
})
```
Rules:
- Use `it.effect(...)` for TestClock/TestConsole tests.
- Use `it.live(...)` for real timers, filesystem mtimes, child processes,
git, locks, or other live integration behavior.
- Use `it.instance(...)` for service tests that need a scoped instance.
- Prefer Effect-aware fixtures from `test/fixture/fixture.ts`.
- Avoid sleeps; wait for real events or deterministic state transitions.
- Avoid mutable `process.env`, `Flag`, or module-global changes after
layers are built.
- Use `Layer.mock` for partial service stubs.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` test
wrappers.
## Verification
From `packages/opencode`:
```bash
bun run typecheck
bun run test -- path/to/test.ts
```
Do not run tests from the repo root; the repo has a guard for that.

View File

@@ -0,0 +1,13 @@
# Instance Context
Instance selection is now Effect-provided context.
Use these APIs:
- `InstanceRef` for the current project context.
- `WorkspaceRef` for the current workspace id.
- `InstanceState.context` / `InstanceState.directory` inside Effect services that require an instance.
- `InstanceStore` at entry boundaries that need to load, reload, or dispose project contexts.
- `EffectBridge` for native, plugin, or plain JavaScript callback boundaries that need to re-enter Effect with captured refs.
Do not add new ambient instance globals. Promise and callback boundaries should either stay in Effect, use `EffectBridge`, or pass the required context explicitly.

View File

@@ -0,0 +1,30 @@
# Effect loose ends
Small follow-ups that do not fit neatly into the main facade, route, tool, or schema migration checklists.
## Config / TUI
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
## ConfigPaths
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
Keep `fileInDirectory(...)` as a plain function.
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `FSUtil.Service` directly.
Initial service surface should cover:
- `projectFiles(...)`
- `directories(...)`
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Notes
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
- When changing config loading internals, rerun the config and TUI suites first before broad package sweeps.

View File

@@ -0,0 +1,62 @@
# Effect Migration Patterns
This is the compact reference for moving code toward the current Effect
shape. The high-level roadmap is [`todo.md`](./todo.md); examples and
rules are in [`guide.md`](./guide.md).
## Default Shape
- Service methods return `Effect`.
- Service methods are named with `Effect.fn("Domain.method")`.
- Expected failures are typed errors on the error channel.
- Dependencies are yielded once at layer construction and closed over by
methods.
- `defaultLayer` wires production dependencies; tests can use open layers
when replacing dependencies.
## Instance State
Use `InstanceState` for per-directory state, subscriptions, scoped
background work, and per-instance cleanup.
Do not add ad hoc `started` flags on top of `InstanceState`; the scoped
cache handles run-once and concurrent deduplication.
## Runtime Boundaries
Prefer `AppRuntime` for crossing from non-Effect code into the shared app
layer.
`makeRuntime(...)` exists for intentional service-local boundaries and
legacy facades. Do not add new service-local runtimes unless the service is
genuinely outside `AppLayer`.
## Platform Edges
- Use `FSUtil.Service` instead of raw filesystem APIs in
effectified services.
- Use `AppProcess.Service` instead of raw process wrappers.
- Use `HttpClient.HttpClient` instead of raw `fetch` in Effect code.
- Use `Effect.cached` for shared in-flight work.
- Use `Effect.callback` for callback APIs.
## Tests During Migration
When migrating code, migrate touched tests toward
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md):
- `testEffect(...)`
- `it.effect`, `it.live`, or `it.instance`
- explicit layers for behavior changes
- deterministic waits instead of sleeps
- no mutable env/global flags after layers are built
## Migration Checklist
- [ ] The code has a single Effect body instead of Promise wrappers around
service calls.
- [ ] Expected failures are typed errors, not thrown exceptions or defects.
- [ ] Layer requirements are explicit.
- [ ] Tests use Effect-aware fixtures and focused layers.
- [ ] Public behavior and wire shapes are preserved unless intentionally
changed.

View File

@@ -0,0 +1,61 @@
# HTTP Route Patterns
Current guidance for `packages/opencode/src/server/routes/instance/httpapi`.
## Handler Shape
Use `HttpApiBuilder.group(...)` for normal JSON and streaming HTTP API
endpoints. Yield stable services once while building the handler layer,
then close over those services in endpoint implementations.
```ts
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
return handlers.handle("list", () => session.list())
}),
)
```
Use raw `HttpRouter` only for routes that do not fit the request/response
HttpApi model, such as WebSocket upgrades or catch-all fallback routes.
Do not rebuild stable layers inside request handlers. Provide stable
services at the route/layer boundary and use request-level provisioning
only for request-derived context.
## Error Boundaries
Expected service errors should be mapped at the handler boundary to
endpoint-declared public HTTP errors. Keep one-off mappings inline. Extract
small helpers when the same mapping repeats.
Generic middleware should not become a domain-error mapper. It should
handle cross-cutting concerns and final unknown-defect fallback.
Public JSON errors should be explicit schema contracts declared on each
endpoint or group. Built-in `HttpApiError.*` is fine only when its generated
body is intentionally the public wire shape.
Preserve existing `{ name, data }` error bodies until a deliberate breaking
API change.
## OpenAPI Compatibility
`public.ts` still owns SDK/OpenAPI compatibility transforms. Shrink those
transforms by tightening source schemas one workaround at a time.
When an OpenAPI-visible source schema changes:
- verify the generated SDK diff is intentional
- preserve legacy compatibility unless the PR explicitly changes it
- prefer source-schema fixes over new post-processing rules
## Checklist For Route PRs
- [ ] Stable services are yielded at handler-layer construction.
- [ ] Expected domain errors are translated at the route boundary.
- [ ] Endpoint/group error schemas describe the public body and status.
- [ ] Middleware does not gain new domain-specific name checks.
- [ ] Raw routes are used only when HttpApi is the wrong abstraction.

View File

@@ -0,0 +1,88 @@
# Schema Migration
Use Effect Schema as the source of truth for domain models, DTOs, IDs,
inputs, outputs, and typed errors.
This is guidance, not an inventory. Do not use this file to track which
schema modules are complete; verify current state with `git grep` before
starting a migration.
## Preferred Shapes
Use `Schema.Class` for exported data objects with a clear domain identity:
```ts
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {}
```
Use `Schema.Struct` for local shapes and simple nested objects:
```ts
const Payload = Schema.Struct({
id: FooID,
value: Schema.String,
})
```
Use `Schema.TaggedErrorClass` for expected domain errors:
```ts
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
```
Use branded schema-backed IDs for single-value domain identifiers.
## Boundary Rule
Effect Schema should own the type. Boundaries should consume Effect Schema
directly or use narrow boundary-specific helpers. Avoid reintroducing a
generic Effect Schema -> Zod bridge.
Current intentional boundaries:
- Public plugin tools still expose Zod through `tool.schema = z`.
- Tool parameters use tool-specific JSON Schema helpers.
- Public config and TUI schema generation goes through the schema script.
- AI SDK object generation uses Standard Schema / JSON Schema helpers.
When Zod must stay temporarily, leave a short note explaining the boundary
or compatibility reason.
## Refinements
Reuse named refinements instead of re-spelling constraints:
```ts
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
```
Prefer domain-named leaf schemas when the name improves callers or error
messages. Avoid adding brands purely for novelty.
## Migration Order
For a domain that still has mixed schemas:
1. Shared leaf models and branded IDs.
2. Exported `Info`, `Input`, `Output`, and event payload types.
3. Expected domain errors.
4. Service-local internal models.
5. HTTP/tool/AI boundary validators.
Keep public wire shapes stable unless the PR is explicitly a breaking API
change.
## Checklist For A PR
- [ ] There is one schema source of truth for each migrated type.
- [ ] Remaining Zod is an intentional boundary choice.
- [ ] Public JSON/OpenAPI output is unchanged or intentionally updated.
- [ ] Derived helpers are narrow and boundary-specific.
- [ ] Tests assert behavior, not duplicated schema implementation details.

View File

@@ -0,0 +1,58 @@
# Server Package Extraction
Practical reference for a future `packages/server` split after the opencode
server moved to the Effect HttpApi backend.
## Current State
- The server still lives in `packages/opencode`.
- The runtime and app layer are centralized in `src/effect/app-runtime.ts` and
`src/effect/run-service.ts`.
- The route tree lives under `src/server/routes/instance/httpapi` and is hosted
from `src/server/server.ts`.
- OpenAPI generation is based on the HttpApi contract plus compatibility
translation in `src/server/routes/instance/httpapi/public.ts`.
- There is no standalone `packages/server` workspace yet.
## Future State
Target package layout:
- `packages/core` - shared domain services and schemas
- `packages/server` - HTTP contracts, handlers, OpenAPI generation, and an
embeddable server API
- `packages/cli` - TUI and CLI entrypoints
- `packages/sdk` - generated from the server OpenAPI spec
- `packages/plugin` - plugin authoring surface
## Extraction Rule
Do not create a package cycle.
Until enough shared service code lives outside `packages/opencode`, a future
`packages/server` should either:
- own pure HttpApi contracts only, or
- accept host-provided services/layers/callbacks from `packages/opencode`
It should not import `packages/opencode` services while `packages/opencode`
imports it to host routes.
## Suggested PR Sequence
1. Keep shrinking OpenAPI compatibility shims in `httpapi/public.ts`.
2. Move stable domain schemas into shared packages only when they no longer
depend on opencode-local runtime modules.
3. Extract pure HttpApi contract modules into `packages/server` once the contract
can compile without importing `packages/opencode` implementation details.
4. Extract handler factories after their service dependencies can be supplied by
a host layer instead of imported directly.
5. Move server hosting last, after package ownership is clear.
## Non-Goals
- Do not revive the old dual-backend migration shape.
- Do not split server hosting before service dependencies have a clean package
boundary.
- Do not switch SDK generation to a new package until generated output is known
to remain compatible.

View File

@@ -0,0 +1,241 @@
# Effect TODO
Short roadmap for Effect cleanup in `packages/opencode`.
Current patterns and examples live in [`guide.md`](./guide.md). Error
boundary migration details live in
[`error-boundaries-plan.md`](./error-boundaries-plan.md). Test migration rules live in
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
Older deep-dive notes in this directory may still be useful, but treat
this roadmap and the guide as the current entry points.
This is a planning map, not a verified inventory. Before starting a task,
re-run a targeted `git grep` from current `dev` and update this file if
the inventory changed.
## Priorities
```text
P0 ERR + RENDER + HTTP
Make expected failures typed, render them well, and stop relying on
generic HTTP error guesswork.
P1 TEST
Convert touched tests to the ideal Effect test patterns from the guide.
P2 RF
Move mutable runtime flags into typed runtime/config services.
P3 GLOBAL
Make global paths explicit and remove import-time side effects.
P4 INST + BRIDGE
Remove ambient Instance coupling while keeping Promise/callback interop.
P5 PROC + FS
Replace raw process/filesystem edges with typed Effect services.
P6 OA
Shrink OpenAPI compatibility shims as source schemas improve.
```
## Work Paths
- `ERR` Typed errors — replace legacy `NamedError.create(...)` and
`Effect.die(...)` for expected service failures with
`Schema.TaggedErrorClass` errors on the Effect error channel.
Shrinks: [`NamedError`](../../../core/src/util/error.ts) usage.
- `RENDER` User-visible error rendering — preserve structured typed-error
details at CLI, HTTP, and tool boundaries.
Shrinks: opaque `Error: Name` rendering.
- `HTTP` HTTP route cleanup — make route errors explicit instead of
relying on generic middleware to guess status/body from error names.
Shrinks: [`middleware/error.ts`](../../src/server/routes/instance/httpapi/middleware/error.ts)
and route-level compatibility shims.
- `TEST` Effect test migration — use `testEffect`, `it.live`, and
`it.instance` with explicit layers.
Shrinks: Promise-style tests, sleeps, mutable global test flags.
- `RF` RuntimeFlags / Flag deletion — move mutable
[`Flag`](../../../core/src/flag/flag.ts) reads into typed runtime/config
services.
Shrinks: [`flag.ts`](../../../core/src/flag/flag.ts),
[`test/fixture/flag.ts`](../../test/fixture/flag.ts).
- `GLOBAL` Global paths / import side effects — make global path state
explicit and testable instead of mutable module state.
Shrinks: [`global.ts`](../../../core/src/global.ts) import-time side
effects, mutable `Global.Path` overrides, and its `Flag` dependency.
- `INST` Instance context — keep project context explicit through Effect refs
and bridge boundaries.
- `BRIDGE` Promise/callback interop — keep bridge helpers, but reduce
legacy ALS coupling.
Shrinks: ad hoc Promise/callback re-entry code.
- `PROC` AppProcess migration — prefer `AppProcess.Service` over raw
process wrappers.
Shrinks: direct spawn callsites and legacy process helpers.
- `FS` FSUtil migration — prefer `FSUtil.Service` over raw
filesystem APIs.
Shrinks: direct `fs` / `Bun.file` service callsites where inappropriate.
- `RT` Runtime/facade cleanup — remove service-local `makeRuntime`
facades when not intentional.
Shrinks: async facade exports around services and
[`run-service.ts`](../../src/effect/run-service.ts) usage.
- `OA` OpenAPI compatibility — tighten source schemas instead of
post-processing generated OpenAPI.
Shrinks: schema workaround blocks in
[`public.ts`](../../src/server/routes/instance/httpapi/public.ts).
## P0: Errors, Rendering, And HTTP
This should be the next big cleanup theme. The codebase is moving toward
typed Effect failures, but the user-facing boundaries still leak old
shapes and sometimes collapse rich errors into opaque strings.
### Problems
- Some expected service failures still use `NamedError.create(...)` or
collapse to `Effect.die(...)`. The storage/worktree/provider-auth
conversions are done; an inventory sweep is needed for the rest.
- HTTP error middleware still guesses status codes from error names —
some entries (e.g. storage `NotFound`, provider auth) can now be
removed, but the middleware overall has not shrunk.
- Route handlers and route groups do not consistently declare the public
error body they intend to expose.
- Repeated route error translations do not yet have a clear home: some
should stay inline, some deserve tiny shared mapper helpers.
### Target Shape
- Services define expected failures with `Schema.TaggedErrorClass`.
- Services export an `Error` union and include it in method return types.
- Expected failures stay on the Effect error channel.
- `Effect.die(...)` is reserved for defects: bugs, impossible states,
violated invariants, or final unknown-boundary fallbacks.
- Inside `Effect.gen` / `Effect.fn`, use `yield* new MyError(...)` for
direct expected failures.
- Domain services do not import HTTP status codes, `HttpApiError`, or
route-specific error schemas.
- HTTP route groups make their public error contracts obvious.
- Handlers map service errors to declared HTTP errors at the boundary.
- Shared mapper helpers are only for repeated translations, not a giant
central registry of every domain error.
- Generic HTTP middleware should shrink; it should not accumulate more
name-based domain knowledge.
### Recently completed
- [x] `RENDER-1` CLI tagged config error rendering (#27256, tests #27257).
- [x] `ERR-1` [`storage/storage.ts`](../../src/storage/storage.ts) typed
`NotFoundError` (#27265) and removal of the server defect fallback
(#27287).
- [x] `ERR-2` [`worktree/index.ts`](../../src/worktree/index.ts) typed
errors (#27296).
- [x] `ERR-3` [`provider/auth.ts`](../../src/provider/auth.ts) typed
validation/oauth errors (#27301).
- [x] `HTTP-1` Unknown-500 details no longer leaked (#27251); follow-up
to stop exposing named defects (#27471).
- [x] Session message reads typed and made effectful (#27269, #27275,
#27280, #27291).
- [x] Session HTTP error contracts tightened (#27308); busy-session
mapping centralized (#27375, #27473).
- [x] Provider init (#27484) and LSP init (#27494) errors typed.
### First PR Candidates
- [ ] `HTTP-2` Audit one route group for explicit error contracts and
decide which mappings stay inline vs. shared helper.
- [ ] `ERR-4` Sweep remaining `NamedError.create(...)` and
`Effect.die(...)` callsites for expected failures — re-run `git
grep` to build a current inventory.
- [ ] `RENDER-2` Audit CLI and TUI surfaces for any remaining opaque
`Error: Name` rendering of typed errors.
## P1: Tests
When touching tests, migrate them toward the ideal patterns in
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md):
- Use `testEffect(...)` with explicit layers.
- Prefer `it.instance(...)` for service tests that need an instance.
- Prefer `it.live(...)` for real timers, filesystem mtimes, child
processes, git, locks, or other live integration behavior.
- Avoid sleeps; wait on real events or deterministic state transitions.
- Do not mutate `process.env` or mutable globals after layers are built.
- Use explicit layer variants, such as `RuntimeFlags.layer(...)`, for
behavior changes.
## P2: RuntimeFlags / Flag Deletion
Recently completed:
- [x] Plugin/pure-mode flags moved to RuntimeFlags.
- [x] Tool visibility flags moved to RuntimeFlags.
- [x] Built-in websearch provider selection uses the same runtime flags as
tool visibility.
- [x] Removed global default-plugin disabling from test preload.
- [x] `RF-1` Reference reads routed through runtime flags (#27318).
- [x] `RF-2` Plan-mode prompt read routed through runtime flags (#27320).
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
- [x] `RF-4` Workspaces reads routed through runtime flags for session
(#27335), sync (#27336), and control-plane (#27337).
- [x] LLM client (#27368) and installation client (#27369) routed
through runtime flags.
- [x] TUI plugin runtime flags simplified (#27506).
- [x] Background-subagents flag moved to RuntimeFlags, then removed
(`refactor(task): use runtime flag for background subagents`,
`refactor(flags): remove background subagents flag`).
Remaining cleanup:
- [ ] Sweep lingering `Flag.*` reads — many CLI/TUI/config/observability
callsites still import [`flag.ts`](../../../core/src/flag/flag.ts).
Decide per-callsite whether to route through RuntimeFlags, accept
as legitimate env/config boundary, or migrate to typed `Config`.
- [ ] Delete [`test/fixture/flag.ts`](../../test/fixture/flag.ts) once
tests no longer mutate `Flag`.
- [ ] Delete [`flag.ts`](../../../core/src/flag/flag.ts) once no packages
import it.
## P3: Global Paths
[`global.ts`](../../../core/src/global.ts) is real connective tissue, not
just cosmetic ugliness. It currently mixes path calculation, import-time
directory creation, `Flock` setup, mutable exported `Path` state, and a
`Flag` dependency.
Problems to reduce:
- Importing the module creates directories.
- Tests override `Global.Path` by mutating exported module state.
- Most callers use `Global.Path` directly instead of the Effect service.
- `Global.make()` still reads mutable `Flag.OPENCODE_CONFIG_DIR`.
Next PR candidates:
- [ ] Replace mutable `Global.Path` test overrides with explicit test
layers or scoped helpers.
- [ ] Move directory creation and `Flock` setup behind an explicit init
boundary where possible.
- [ ] Remove the `Flag` dependency from global path resolution.
## P4: Instance And Bridge
Instance context migration is complete for the legacy sync shim. Promise and callback interop continues through [`effect/bridge.ts`](../../src/effect/bridge.ts).
Current rules:
- Effect services read instance data from `InstanceRef`, `WorkspaceRef`, `InstanceState`, or explicit arguments.
- Plain JavaScript callback boundaries use `EffectBridge` or explicit context arguments.
- Runtime entrypoints must provide refs explicitly when they are instance-scoped.
## Lower Priority Tracks
- `PROC` / `FS` — continue AppProcess and FSUtil migrations as
focused PRs when touching relevant files.
- `RT` — remove service-local runtime facades only when they are not an
intentional boundary.
- `OA` — shrink [`public.ts`](../../src/server/routes/instance/httpapi/public.ts)
by tightening source schemas one workaround at a time.
- `fetch``HttpClient` — migrate raw fetch callsites when the caller is
already effectful or being effectified.
- `Tools` — remaining tool cleanup is narrow: `webfetch` HTML extraction
and `shell` raw stream/promise edges.

View File

@@ -0,0 +1,88 @@
# Tool migration
Practical reference for the current tool-migration state in `packages/opencode`.
## Status
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the built-in tool surface is now largely on the target shape.
The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-based initialization, and nearly all of them already build their tool body with `Effect.gen(...)` and `Effect.fn(...)`.
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
1. remove Promise and raw platform bridges inside individual tool bodies
2. swap tool internals to Effect-native services like `FSUtil`, `HttpClient`, and `ChildProcessSpawner`
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
## Current shape
`Tool.define(...)` is already the Effect-native helper here.
- `init` is an `Effect`
- `info.init()` returns an `Effect`
- `execute(...)` returns an `Effect`
That means a tool does not need a separate `Tool.defineEffect(...)` helper to count as migrated. A tool is effectively migrated when its init and execute path stay Effect-native, even if some internals still bridge to Promise-based or raw APIs.
## Tests
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps tool tests aligned with the production service graph and makes follow-up cleanup mostly mechanical.
## Exported tools
These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
- [x] `edit.ts`
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `lsp.ts`
- [x] `plan.ts`
- [x] `question.ts`
- [x] `read.ts`
- [x] `skill.ts`
- [x] `task.ts`
- [x] `todo.ts`
- [x] `webfetch.ts`
- [x] `websearch.ts`
- [x] `write.ts`
Notes:
- There is no current `ls.ts` tool file on this branch.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
## Follow-up cleanup
Most exported tools are already on the intended Effect-native shape. The remaining cleanup is narrower than the old checklist implied.
Current spot cleanups worth tracking:
- [x] `read.ts` — streams through `FSUtil.Service.stream` with `Stream.splitLines`; the legacy Node stream / `readline` helper is gone
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [x] `patch/index.ts` — apply path now returns `Effect` over `FSUtil.Service`; the parser and chunk replacer stay pure
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
- `apply_patch.ts`
- `grep.ts`
- `write.ts`
- `websearch.ts`
- `edit.ts`
## Filesystem notes
Current raw fs users that still appear relevant here:
- `file/ripgrep.ts``fs/promises`