chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
# 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`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.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()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## 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. `src/file/index.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 AppRuntime.runPromise(File.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. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
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`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## 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] `src/file/index.ts` (`File`) - facades removed and merged
- [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,381 @@
# HttpApi migration
Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition.
## End State
- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`.
- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware.
- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge.
- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline.
- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction.
## Current State
- `OPENCODE_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono.
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes.
- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation.
- `HttpApi` auth is independent of Hono auth.
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`.
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`.
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
## Migration Rules
- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs.
- Migrate one route group, or one coherent subset of a route group, at a time.
- Reuse existing services. Do not re-architect service logic during HTTP boundary migration.
- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces.
- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected.
- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry.
## Route Slice Checklist
Use this checklist for each small HttpApi migration PR:
1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes.
2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`.
3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block.
4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals.
5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances.
6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation.
7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context.
8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root.
## Hono Deletion Checklist
Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough.
1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status.
2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed.
4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path.
5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted.
6. The legacy Hono `describeRoute`, validator, and handler for that path are removed.
7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema.
8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects.
9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown.
10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit.
Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior.
## Experimental Read Slice Guidance
For the experimental route group, port read-only JSON routes before mutations:
- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`.
- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`.
- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior.
- Preserve response headers such as pagination cursors if a route is ported.
- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization.
## Schema Notes
- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects.
- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional.
- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful.
- Avoid parallel hand-written Zod and Effect definitions for the same route boundary.
## Phases
### 1. Stabilize The Bridge
Before porting more routes, cover the bridge behavior that every route depends on.
- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly.
- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials.
- Cover `directory` and `x-opencode-directory` instance selection.
- Verify generated SDK output remains unchanged for non-SDK work.
- Fix or remove any implemented-but-unmounted `HttpApi` groups.
### 2. Complete The Inventory
Create a route inventory from the actual Hono registrations and classify each route.
Statuses:
- `bridged`: served through the `HttpApi` bridge when the flag is on.
- `implemented`: `HttpApi` group exists but is not mounted through Hono.
- `next`: good JSON candidate for near-term porting.
- `later`: portable, but needs schema/service cleanup first.
- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`.
### 3. Finish JSON Route Parity
Port remaining JSON routes in small batches.
Good near-term candidates:
- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter`
- simple mutations: `POST /instance/dispose`
- experimental JSON reads: console, tool, worktree list, resource list
- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows
Keep large or stateful groups for later:
- `session`
- `sync`
- process-level experimental routes
### 4. Move OpenAPI And SDK Generation
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
Required before route deletion:
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes.
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
- Compare generated SDK output against `dev` for every route group deletion.
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
### 5. Make HttpApi Default For JSON Routes
After JSON parity and SDK generation are covered:
- Flip the bridge default for ported JSON routes.
- Keep a short-lived fallback flag for the old Hono implementation.
- Run the same tests against both the default and fallback path during rollout.
- Stop adding new Hono handlers for JSON routes once the default flips.
### 6. Delete Hono Route Implementations
Delete Hono routes group-by-group after each group meets the deletion criteria.
Deletion criteria:
- `HttpApi` route is mounted by default.
- Behavior is covered by bridge-level tests.
- OpenAPI/SDK generation comes from Effect for that path.
- SDK diff is zero or explicitly accepted.
- Legacy Hono route is no longer needed as a fallback.
After deleting a group:
- Remove its Hono route file or dead endpoints.
- Remove its `.route(...)` registration from `instance/index.ts`.
- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type.
- Regenerate SDK and verify output.
### 7. Replace Special Routes
Special routes need explicit designs before Hono can disappear completely.
- `event`: SSE
- `pty`: websocket
- `tui`: UI/control bridge behavior
- streaming `session` endpoints
Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`.
## Current Route Status
| Area | Status | Notes |
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
| `question` | `bridged` | `GET /question`, reply, reject |
| `permission` | `bridged` | list and reply |
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
| `config` | `bridged` | read, providers, update |
| `project` | `bridged` | list, current, git init, update |
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
| `sync` | `bridged` | start/replay/history |
| `event` | `bridged` | SSE via raw Effect HTTP |
| `pty` | `special` | websocket |
| `tui` | `special` | UI bridge |
## Full Route Checklist
This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above.
### Top-Level Instance Routes
- [x] `POST /instance/dispose` - dispose active instance after response.
- [x] `GET /path` - current directory and worktree paths.
- [x] `GET /vcs` - current VCS status.
- [x] `GET /vcs/diff` - VCS diff summary.
- [x] `GET /command` - command catalog.
- [x] `GET /agent` - agent catalog.
- [x] `GET /skill` - skill catalog.
- [x] `GET /lsp` - LSP status.
- [x] `GET /formatter` - formatter status.
### Config Routes
- [x] `GET /config` - read config.
- [x] `PATCH /config` - update config and dispose active instance after response.
- [x] `GET /config/providers` - config provider summary.
### Project Routes
- [x] `GET /project` - list projects.
- [x] `GET /project/current` - current project.
- [x] `POST /project/git/init` - initialize git and reload active instance after response.
- [x] `PATCH /project/:projectID` - update project metadata.
### Provider Routes
- [x] `GET /provider` - list providers.
- [x] `GET /provider/auth` - list provider auth methods.
- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth.
- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth.
### Question Routes
- [x] `GET /question` - list questions.
- [x] `POST /question/:requestID/reply` - reply to question.
- [x] `POST /question/:requestID/reject` - reject question.
### Permission Routes
- [x] `GET /permission` - list permission requests.
- [x] `POST /permission/:requestID/reply` - reply to permission request.
### File Routes
- [x] `GET /find` - text search.
- [x] `GET /find/file` - file search.
- [x] `GET /find/symbol` - symbol search.
- [x] `GET /file` - list directory entries.
- [x] `GET /file/content` - read file content.
- [x] `GET /file/status` - file status.
### MCP Routes
- [x] `GET /mcp` - MCP status.
- [x] `POST /mcp` - add MCP server at runtime.
- [x] `POST /mcp/:name/auth` - start MCP OAuth.
- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback.
- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow.
- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials.
- [x] `POST /mcp/:name/connect` - connect MCP server.
- [x] `POST /mcp/:name/disconnect` - disconnect MCP server.
### Experimental Routes
- [x] `GET /experimental/console` - active Console provider metadata.
- [x] `GET /experimental/console/orgs` - switchable Console orgs.
- [x] `POST /experimental/console/switch` - switch active Console org.
- [x] `GET /experimental/tool/ids` - tool IDs.
- [x] `GET /experimental/tool` - tools for provider/model.
- [x] `GET /experimental/worktree` - list worktrees.
- [x] `POST /experimental/worktree` - create worktree.
- [x] `DELETE /experimental/worktree` - remove worktree.
- [x] `POST /experimental/worktree/reset` - reset worktree.
- [x] `GET /experimental/session` - global session list.
- [x] `GET /experimental/resource` - MCP resources.
### Workspace Routes
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
- [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status.
- [x] `DELETE /experimental/workspace/:id` - remove workspace.
- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace.
### Sync Routes
- [x] `POST /sync/start` - start workspace sync.
- [x] `POST /sync/replay` - replay sync events.
- [x] `POST /sync/history` - list sync event history.
### Session Routes
- [x] `GET /session` - list sessions.
- [x] `GET /session/status` - session status map.
- [x] `GET /session/:sessionID` - get session.
- [x] `GET /session/:sessionID/children` - get child sessions.
- [x] `GET /session/:sessionID/todo` - get session todos.
- [x] `POST /session` - create session.
- [x] `DELETE /session/:sessionID` - delete session.
- [x] `PATCH /session/:sessionID` - update session metadata.
- [x] `POST /session/:sessionID/init` - run project init command.
- [x] `POST /session/:sessionID/fork` - fork session.
- [x] `POST /session/:sessionID/abort` - abort session.
- [x] `POST /session/:sessionID/share` - share session.
- [x] `GET /session/:sessionID/diff` - session diff.
- [x] `DELETE /session/:sessionID/share` - unshare session.
- [x] `POST /session/:sessionID/summarize` - summarize session.
- [x] `GET /session/:sessionID/message` - list session messages.
- [x] `GET /session/:sessionID/message/:messageID` - get message.
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
- [x] `POST /session/:sessionID/message` - prompt with streaming response.
- [x] `POST /session/:sessionID/prompt_async` - async prompt.
- [x] `POST /session/:sessionID/command` - run command.
- [x] `POST /session/:sessionID/shell` - run shell command.
- [x] `POST /session/:sessionID/revert` - revert message.
- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
### Event Routes
- [x] `GET /event` - SSE event stream via raw Effect HTTP.
### PTY Routes
- [x] `GET /pty` - list PTY sessions.
- [x] `POST /pty` - create PTY session.
- [x] `GET /pty/:ptyID` - get PTY session.
- [x] `PUT /pty/:ptyID` - update PTY session.
- [x] `DELETE /pty/:ptyID` - remove PTY session.
- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support.
### TUI Routes
- [x] `POST /tui/append-prompt` - append prompt.
- [x] `POST /tui/open-help` - open help.
- [x] `POST /tui/open-sessions` - open sessions.
- [x] `POST /tui/open-themes` - open themes.
- [x] `POST /tui/open-models` - open models.
- [x] `POST /tui/submit-prompt` - submit prompt.
- [x] `POST /tui/clear-prompt` - clear prompt.
- [x] `POST /tui/execute-command` - execute command.
- [x] `POST /tui/show-toast` - show toast.
- [x] `POST /tui/publish` - publish TUI event.
- [x] `POST /tui/select-session` - select session.
- [x] `GET /tui/control/next` - get next TUI request.
- [x] `POST /tui/control/response` - submit TUI control response.
## Remaining PR Plan
Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable.
1. [x] Bridge `PATCH /project/:projectID`.
2. [x] Bridge MCP add/connect/disconnect routes.
3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
4. [x] Bridge experimental console switch and tool list routes.
5. [x] Bridge experimental global session list.
6. [x] Bridge workspace create/remove/session-restore routes.
7. [x] Bridge sync start/replay/history routes.
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP.
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
## Checklist
- [x] Add first `HttpApi` JSON route slices.
- [x] Bridge selected `HttpApi` routes into Hono behind `OPENCODE_EXPERIMENTAL_HTTPAPI`.
- [x] Reuse existing Effect services in handlers.
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
- [x] Attach auth middleware in route modules.
- [x] Support `auth_token` as a query security scheme.
- [x] Add bridge-level auth and instance tests.
- [x] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [x] Port remaining top-level JSON reads.
- [ ] Generate SDK/OpenAPI from Effect routes.
- [ ] Flip ported JSON routes to default-on with fallback.
- [ ] Delete replaced Hono route implementations.
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations.

View File

@@ -0,0 +1,309 @@
# Instance context migration
Practical plan for retiring the promise-backed / ALS-backed `Instance` helper in `src/project/instance.ts` and moving instance selection fully into Effect-provided scope.
## Goal
End state:
- request, CLI, TUI, and tool entrypoints shift into an instance through Effect, not `Instance.provide(...)`
- Effect code reads the current instance from `InstanceRef` or its eventual replacement, not from ALS-backed sync getters
- per-directory boot, caching, and disposal are scoped Effect resources, not a module-level `Map<string, Promise<InstanceContext>>`
- ALS remains only as a temporary bridge for native callback APIs that fire outside the Effect fiber tree
## Current split
Today `src/project/instance.ts` still owns two separate concerns:
- ambient current-instance context through `LocalContext` / `AsyncLocalStorage`
- per-directory boot and deduplication through `cache: Map<string, Promise<InstanceContext>>`
At the same time, the Effect side already exists:
- `src/effect/instance-ref.ts` provides `InstanceRef` and `WorkspaceRef`
- `src/effect/run-service.ts` already attaches those refs when a runtime starts inside an active instance ALS context
- `src/effect/instance-state.ts` already prefers `InstanceRef` and only falls back to ALS when needed
That means the migration is not "invent instance context in Effect". The migration is "stop relying on the legacy helper as the primary source of truth".
## End state shape
Near-term target shape:
```ts
InstanceScope.with({ directory, workspaceID }, effect)
```
Responsibilities of `InstanceScope.with(...)`:
- resolve `directory`, `project`, and `worktree`
- acquire or reuse the scoped per-directory instance environment
- provide `InstanceRef` and `WorkspaceRef`
- run the caller's Effect inside that environment
Code inside the boundary should then do one of these:
```ts
const ctx = yield * InstanceState.context
const dir = yield * InstanceState.directory
```
Long-term, once `InstanceState` itself is replaced by keyed layers / `LayerMap`, those reads can move to an `InstanceContext` service without changing the outer migration order.
## Migration phases
### Phase 1: stop expanding the legacy surface
Rules for all new code:
- do not add new `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current` reads inside Effect code
- do not add new `Instance.provide(...)` boundaries unless there is no Effect-native seam yet
- use `InstanceState.context`, `InstanceState.directory`, or an explicit `ctx` parameter inside Effect code
Success condition:
- the file inventory below only shrinks from here
### Phase 2: remove direct sync getter reads from Effect services
Convert Effect services first, before replacing the top-level boundary. These modules already run inside Effect and mostly need `yield* InstanceState.context` or a yielded `ctx` instead of ambient sync access.
Primary batch, highest payoff:
- `src/file/index.ts`
- `src/lsp/server.ts`
- `src/worktree/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
- `src/session/index.ts`
- `src/project/vcs.ts`
Mechanical replacement rule:
- `Instance.directory` -> `ctx.directory` or `yield* InstanceState.directory`
- `Instance.worktree` -> `ctx.worktree`
- `Instance.project` -> `ctx.project`
Do not thread strings manually through every public method if the service already has access to Effect context.
### Phase 3: convert entry boundaries to provide instance refs directly
After the service bodies stop assuming ALS, move the top-level boundaries to shift into Effect explicitly.
Main boundaries:
- HTTP server middleware and experimental `HttpApi` entrypoints
- CLI commands
- TUI worker / attach / thread entrypoints
- tool execution entrypoints
These boundaries should become Effect-native wrappers that:
- decode directory / workspace inputs
- resolve the instance context once
- provide `InstanceRef` and `WorkspaceRef`
- run the requested Effect
At that point `Instance.provide(...)` becomes a legacy adapter instead of the normal code path.
### Phase 4: replace promise boot cache with scoped instance runtime
Once boundaries and services both rely on Effect context, replace the module-level promise cache in `src/project/instance.ts`.
Target replacement:
- keyed scoped runtime or keyed layer acquisition for each directory
- reuse via `ScopedCache`, `LayerMap`, or another keyed Effect resource manager
- cleanup performed by scope finalizers instead of `disposeAll()` iterating a Promise map
This phase should absorb the current responsibilities of:
- `cache` in `src/project/instance.ts`
- `boot(...)`
- most of `disposeInstance(...)`
- manual `reload(...)` / `disposeAll()` fan-out logic
### Phase 5: shrink ALS to callback bridges only
Keep ALS only where a library invokes callbacks outside the Effect fiber tree and we still need to call code that reads instance context synchronously.
Known bridge cases today:
- `src/file/watcher.ts`
- `src/session/llm.ts`
- some LSP and plugin callback paths
If those libraries become fully wrapped in Effect services, the remaining `Instance.bind(...)` uses can disappear too.
### Phase 6: delete the legacy sync API
Only after earlier phases land:
- remove broad use of `Instance.current`, `Instance.directory`, `Instance.worktree`, `Instance.project`
- reduce `src/project/instance.ts` to a thin compatibility shim or delete it entirely
- remove the ALS fallback from `InstanceState.context`
## Inventory of direct legacy usage
Direct legacy usage means any source file that still calls one of:
- `Instance.current`
- `Instance.directory`
- `Instance.worktree`
- `Instance.project`
- `Instance.provide(...)`
- `Instance.bind(...)`
- `Instance.restore(...)`
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
These files define or adapt the current bridge. They should change last, after callers have moved.
- `src/project/instance.ts`
- `src/effect/run-service.ts`
- `src/effect/instance-state.ts`
- `src/project/bootstrap.ts`
- `src/config/config.ts`
Migration rule:
- keep these as compatibility glue until the outer boundaries and inner services stop depending on ALS
### HTTP and server boundaries
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
- move these to explicit Effect entrypoints that provide `InstanceRef` / `WorkspaceRef`
- do not move these first; first reduce the number of downstream handlers and services that still expect ambient ALS
### CLI and TUI boundaries
These commands still enter an instance through `Instance.provide(...)` or read sync getters directly.
- `src/cli/bootstrap.ts`
- `src/cli/cmd/agent.ts`
- `src/cli/cmd/debug/agent.ts`
- `src/cli/cmd/debug/ripgrep.ts`
- `src/cli/cmd/github.ts`
- `src/cli/cmd/import.ts`
- `src/cli/cmd/mcp.ts`
- `src/cli/cmd/models.ts`
- `src/cli/cmd/plug.ts`
- `src/cli/cmd/pr.ts`
- `src/cli/cmd/providers.ts`
- `src/cli/cmd/stats.ts`
- `src/cli/cmd/tui/attach.ts`
- `src/cli/cmd/tui/plugin/runtime.ts`
- `src/cli/cmd/tui/thread.ts`
- `src/cli/cmd/tui/worker.ts`
Migration rule:
- converge these on one shared `withInstance(...)` Effect entry helper instead of open-coded `Instance.provide(...)`
- after that helper is proven, inline the legacy implementation behind an Effect-native scope provider
### Tool boundary code
These tools mostly use direct getters for path resolution and repo-relative display logic.
- `src/tool/apply_patch.ts`
- `src/tool/bash.ts`
- `src/tool/edit.ts`
- `src/tool/lsp.ts`
- `src/tool/plan.ts`
- `src/tool/read.ts`
- `src/tool/write.ts`
Migration rule:
- expose the current instance as an explicit Effect dependency for tool execution
- keep path logic local; avoid introducing another global singleton for tool state
### Effect services still reading ambient instance state
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
- `src/lsp/client.ts`
- `src/lsp/index.ts`
- `src/lsp/server.ts`
- `src/mcp/index.ts`
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
- `src/sync/index.ts`
- `src/worktree/index.ts`
Migration rule:
- replace direct getter reads with `yield* InstanceState.context` or a yielded `ctx`
- isolate `Instance.bind(...)` callers and convert only the truly callback-driven edges to bridge mode
### Highest-churn hotspots
Current highest direct-usage counts by file:
- `src/file/index.ts` - `18`
- `src/lsp/server.ts` - `14`
- `src/worktree/index.ts` - `12`
- `src/file/watcher.ts` - `9`
- `src/cli/cmd/mcp.ts` - `8`
- `src/format/formatter.ts` - `8`
- `src/tool/apply_patch.ts` - `8`
- `src/cli/cmd/github.ts` - `7`
These files should drive the first measurable burn-down.
## Recommended implementation order
1. Migrate direct getter reads inside Effect services, starting with `file`, `lsp`, `worktree`, `format`, and `session`.
2. Add one shared Effect-native boundary helper for CLI / tool / HTTP entrypoints so we stop open-coding `Instance.provide(...)`.
3. Move experimental `HttpApi` entrypoints to that helper so the new server stack proves the pattern.
4. Convert remaining CLI and tool boundaries.
5. Replace the promise cache with a keyed scoped runtime or keyed layer map.
6. Delete ALS fallback paths once only callback bridges still depend on them.
## Definition of done
This migration is done when all of the following are true:
- new requests and commands enter an instance by providing Effect context, not ALS
- Effect services no longer read `Instance.directory`, `Instance.worktree`, `Instance.project`, or `Instance.current`
- `Instance.provide(...)` is gone from normal request / CLI / tool execution
- per-directory boot and disposal are handled by scoped Effect resources
- `Instance.bind(...)` is either gone or confined to a tiny set of native callback adapters
## Tracker and worktree
Active tracker items:
- `lh7l73` - overall `HttpApi` migration
- `yobwlk` - remove direct `Instance.*` reads inside Effect services
- `7irl1e` - replace `InstanceState` / legacy instance caching with keyed Effect layers
Dedicated worktree for this transition:
- path: `/Users/kit/code/open-source/opencode-worktrees/instance-effect-shift`
- branch: `kit/instance-effect-shift`

View File

@@ -0,0 +1,34 @@
# 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 `AppFileSystem.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.
## Instance cleanup
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## 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,299 @@
# 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, 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`.
## Instance context transition
See `instance-context.md` for the phased plan to remove the legacy ALS / promise-backed `Instance` helper and move request / CLI / tool boundaries onto Effect-provided instance scope.
## Service shape
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
```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 Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
export * as Foo from "."
```
Rules:
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
- Use `Effect.fn("Foo.method")` for Effect methods
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## 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`:
```ts
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:
```ts
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.):
```ts
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.
```ts
// 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
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
- [x] `Account``account/index.ts`
- [x] `Agent``agent/agent.ts`
- [x] `AppFileSystem``filesystem/index.ts`
- [x] `Auth``auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
- [x] `Bus``bus/index.ts`
- [x] `Command``command/index.ts`
- [x] `Config``config/config.ts`
- [x] `Discovery``skill/discovery.ts` (dependency-only layer, no standalone runtime)
- [x] `File``file/index.ts`
- [x] `FileWatcher``file/watcher.ts`
- [x] `Format``format/index.ts`
- [x] `Installation``installation/index.ts`
- [x] `LSP``lsp/index.ts`
- [x] `MCP``mcp/index.ts`
- [x] `McpAuth``mcp/auth.ts`
- [x] `Permission``permission/index.ts`
- [x] `Plugin``plugin/index.ts`
- [x] `Project``project/project.ts`
- [x] `ProviderAuth``provider/auth.ts`
- [x] `Pty``pty/index.ts`
- [x] `Question``question/index.ts`
- [x] `SessionStatus``session/status.ts`
- [x] `Skill``skill/index.ts`
- [x] `Snapshot``snapshot/index.ts`
- [x] `ToolRegistry``tool/registry.ts`
- [x] `Truncate``tool/truncate.ts`
- [x] `Vcs``project/vcs.ts`
- [x] `Worktree``worktree/index.ts`
- [x] `Session``session/index.ts`
- [x] `SessionProcessor``session/processor.ts`
- [x] `SessionPrompt``session/prompt.ts`
- [x] `SessionCompaction``session/compaction.ts`
- [x] `SessionSummary``session/summary.ts`
- [x] `SessionRevert``session/revert.ts`
- [x] `Instruction``session/instruction.ts`
- [x] `SystemPrompt``session/system.ts`
- [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
- [x] `SessionTodo``session/todo.ts`
Still open at the service-shape level:
- [ ] `SyncEvent``sync/index.ts` (deferred pending sync with James)
- [ ] `Workspace``control-plane/workspace.ts` (deferred pending sync with James)
## Tool migration
Tool-specific migration guidance and checklist live in `tools.md`.
## Effect service adoption in already-migrated code
Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` in their implementation or helper modules. These are low-hanging fruit — the layers already exist, they just need the dependency swap.
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
- [x] `config/config.ts``installDependencies()` now uses `AppFileSystem`
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`)
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
## Filesystem consolidation
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
Tool-specific filesystem cleanup notes live in `tools.md`.
## 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
## Destroying the facades
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
For each service, the migration is roughly:
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
### Pitfalls
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
- **`Effect.tryPromise``yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
### Migration log
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
Route-handler migration guidance and checklist live in `routes.md`.

View File

@@ -0,0 +1,64 @@
# Route handler effectification
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
## Goal
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one.
This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before - one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After - one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
## Rules
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
- Yield services from context instead of calling async facades repeatedly.
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
## Current route files
Current instance route files live under `src/server/routes/instance`.
Files that are already mostly on the intended service-yielding shape:
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service`
Files still worth tracking here:
- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
## Notes
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.

View File

@@ -0,0 +1,400 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from
Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@/util/effect-zod`.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
moves to `@effect/platform`, every Schema-first DTO can flow through
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
`effect-zod` walker plus every `.zod` static can be deleted.
## Preferred shapes
### Data objects
Use `Schema.Class` for structured data.
```ts
export class Info extends Schema.Class<Info>("Foo.Info")({
id: FooID,
name: Schema.String,
enabled: Schema.Boolean,
}) {
static readonly zod = zod(Info)
}
```
If the class cannot reference itself cleanly during initialization, use the
two-step `withStatics` pattern:
```ts
export const Info = Schema.Struct({
id: FooID,
name: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) })))
```
### Errors
Use `Schema.TaggedErrorClass` for domain errors.
```ts
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
id: FooID,
}) {}
```
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose
`static readonly zod` for compatibility when callers still expect Zod.
### Refinements
Reuse named refinements instead of re-spelling `z.number().int().positive()`
in every schema. The `effect-zod` walker translates the Effect versions into
the corresponding zod methods, so JSON Schema output (`type: integer`,
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
```ts
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
```
See `test/util/effect-zod.test.ts` for the full set of translated checks.
## Compatibility rule
During migration, route validators, tool parameters, and any existing
Zod-based boundary should consume the derived `.zod` schema instead of
maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete
boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently
creating a parallel schema source of truth.
## Escape hatches
The walker in `@/util/effect-zod` exposes two explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted.
### `ZodOverride` annotation
Replaces the entire derivation with a hand-crafted zod schema. Used when:
- the target carries external `$ref` metadata (e.g.
`config/model-id.ts` points at `https://models.dev/...`)
- the target is a zod-only schema that cannot yet be expressed as Schema
(e.g. `ConfigAgent.Info`, `Log.Level`)
### Local `DeepMutable<T>` in `config/config.ts`
`Schema.Struct` produces `readonly` types. Some consumer code (notably the
`Config` service) mutates `Info` objects directly, so a readonly-stripping
utility is needed when casting the derived zod schema's output type.
`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
`unknown` to `{}` in the fallback branch — a bug that affects any schema
using `Schema.Record(String, Schema.Unknown)`.
Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
`Types.DeepMutable` used directly.
## Ordering
Migrate in this order:
1. Shared leaf models and `schema.ts` files
2. Exported `Info`, `Input`, `Output`, and DTO types
3. Tagged domain errors
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly
mechanical.
## Progress tracker
### `src/config/` ✅ complete
All of `packages/opencode/src/config/` has been migrated. Files that still
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
type annotations — the `export const <Info|Spec>` values are all Effect
Schema at source.
A file is considered "done" when:
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
are authored as Effect Schema
- any remaining zod is either a derived compat bridge (via `zod()` /
`zodObject()`), a `z.ZodType` type annotation, or a documented
`ZodOverride` escape hatch — never a hand-written parallel source of truth
Files that meet this bar but still carry a compat bridge are checked off
with an inline note describing the bridge and what unblocks its removal.
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
- [x] server, layout
- [x] keybinds
- [x] permission#Info
- [x] agent
- [x] config.ts root
### `src/*/schema.ts` leaf modules
These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain.
- [x] `src/account/schema.ts`
- [x] `src/control-plane/schema.ts`
- [x] `src/permission/schema.ts`
- [x] `src/project/schema.ts`
- [x] `src/provider/schema.ts`
- [x] `src/pty/schema.ts`
- [x] `src/question/schema.ts`
- [x] `src/session/schema.ts`
- [x] `src/storage/schema.ts`
- [x] `src/sync/schema.ts`
- [x] `src/tool/schema.ts`
- [x] `src/util/schema.ts`
### Session domain
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.
Suggested order for this cluster, starting from the leaves that `session.ts`
and the SSE/event surface depend on:
1. `src/session/schema.ts` ✅ already migrated
2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs
3. `src/lsp/*` schema leaves needed by `LSP.Range`
4. `src/snapshot/*` leaves used by `Snapshot.FileDiff`
5. `src/session/message-v2.ts`
6. `src/session/message.ts`
7. `src/session/prompt.ts`
8. `src/session/revert.ts`
9. `src/session/summary.ts`
10. `src/session/status.ts`
11. `src/session/todo.ts`
12. `src/session/session.ts`
13. `src/session/compaction.ts`
Dependency sketch:
```text
session.ts
|- project/schema.ts
|- control-plane/schema.ts
|- permission/schema.ts
|- snapshot/*
|- message-v2.ts
| |- provider/schema.ts
| |- lsp/*
| |- snapshot/*
| |- sync/index.ts
| `- bus/bus-event.ts
|- sync/index.ts
|- bus/bus-event.ts
`- util/update-schema.ts
```
Working rule for this cluster:
- migrate reusable leaf schemas and nested payload objects first
- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as
named Schema values
- leave zod-only event/update helpers in place temporarily when converting
them would force unrelated churn across sync/bus boundaries
`message-v2.ts` first-pass outline:
1. Schema-backed imports already available
- `SessionID`, `MessageID`, `PartID`
- `ProviderID`, `ModelID`
2. Local leaf objects to extract and migrate first
- output format payloads
- common part bases like `PartBase`
- timestamp/range helper objects like `time.start/end`
- file/source helper objects
- token/cost/model helper objects
3. Part variants built from those leaves
- `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart`
- `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart`
- retry/step/tool related parts
4. Higher-level unions and DTOs
- `FilePartSource`
- part unions
- message unions and assistant/user payloads
5. Errors and event payloads last
- `NamedError.create(...)` shapes can stay temporarily if converting them to
`Schema.TaggedErrorClass` would force unrelated churn
- `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can use
derived `.zod` at remaining zod-based HTTP/OpenAPI boundaries
Possible later tightening after the Schema-first migration is stable:
- promote repeated opaque strings and timestamp numbers into branded/newtype
leaf schemas where that adds domain value without changing the wire format
- [x] `src/session/compaction.ts`
- [x] `src/session/message-v2.ts`
- [x] `src/session/message.ts`
- [x] `src/session/prompt.ts`
- [x] `src/session/revert.ts`
- [x] `src/session/session.ts`
- [x] `src/session/status.ts`
- [x] `src/session/summary.ts`
- [x] `src/session/todo.ts`
### Provider domain
- [x] `src/provider/auth.ts`
- [x] `src/provider/models.ts`
- [x] `src/provider/provider.ts`
### Tool schemas
Each tool declares its parameters via a zod schema. Tools are consumed by
both the in-process runtime and the AI SDK's tool-calling layer, so the
emitted JSON Schema must stay byte-identical.
- [x] `src/tool/apply_patch.ts`
- [x] `src/tool/bash.ts`
- [x] `src/tool/codesearch.ts`
- [x] `src/tool/edit.ts`
- [x] `src/tool/glob.ts`
- [x] `src/tool/grep.ts`
- [x] `src/tool/invalid.ts`
- [x] `src/tool/lsp.ts`
- [x] `src/tool/plan.ts`
- [x] `src/tool/question.ts`
- [x] `src/tool/read.ts`
- [x] `src/tool/registry.ts`
- [x] `src/tool/skill.ts`
- [x] `src/tool/task.ts`
- [x] `src/tool/todo.ts`
- [x] `src/tool/tool.ts`
- [x] `src/tool/webfetch.ts`
- [x] `src/tool/websearch.ts`
- [x] `src/tool/write.ts`
### HTTP route boundaries
Every file in `src/server/routes/` uses hono-openapi with zod validators for
route inputs/outputs. Migrating these individually is the last step; most
will switch to `.zod` derived from the Schema-migrated domain types above,
which means touching them is largely mechanical once the domain side is
done.
- [ ] `src/server/error.ts`
- [x] `src/server/event.ts`
- [x] `src/server/projectors.ts`
- [ ] `src/server/routes/control/index.ts`
- [ ] `src/server/routes/control/workspace.ts`
- [ ] `src/server/routes/global.ts`
- [ ] `src/server/routes/instance/index.ts`
- [ ] `src/server/routes/instance/config.ts`
- [ ] `src/server/routes/instance/event.ts`
- [ ] `src/server/routes/instance/experimental.ts`
- [ ] `src/server/routes/instance/file.ts`
- [ ] `src/server/routes/instance/mcp.ts`
- [ ] `src/server/routes/instance/permission.ts`
- [ ] `src/server/routes/instance/project.ts`
- [ ] `src/server/routes/instance/provider.ts`
- [ ] `src/server/routes/instance/pty.ts`
- [ ] `src/server/routes/instance/question.ts`
- [ ] `src/server/routes/instance/session.ts`
- [ ] `src/server/routes/instance/sync.ts`
- [ ] `src/server/routes/instance/tui.ts`
The bigger prize for this group is the `@effect/platform` HTTP migration
described in `specs/effect/http-api.md`. Once that lands, every one of
these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
so the Schema-first domain types become a prerequisite rather than a
sibling task.
### Everything else
Small / shared / control-plane / CLI. Mostly independent; can be done
piecewise.
- [ ] `src/acp/agent.ts`
- [ ] `src/agent/agent.ts`
- [x] `src/bus/bus-event.ts`
- [ ] `src/bus/index.ts`
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
- [ ] `src/cli/cmd/tui/config/tui.ts`
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [x] `src/control-plane/adaptors/worktree.ts`
- [x] `src/control-plane/types.ts`
- [x] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
- [ ] `src/file/ripgrep.ts`
- [ ] `src/file/watcher.ts`
- [ ] `src/format/index.ts`
- [ ] `src/id/id.ts`
- [ ] `src/ide/index.ts`
- [ ] `src/installation/index.ts`
- [ ] `src/lsp/client.ts`
- [ ] `src/lsp/lsp.ts`
- [ ] `src/mcp/auth.ts`
- [ ] `src/patch/index.ts`
- [ ] `src/plugin/github-copilot/models.ts`
- [ ] `src/project/project.ts`
- [ ] `src/project/vcs.ts`
- [ ] `src/pty/index.ts`
- [ ] `src/skill/index.ts`
- [ ] `src/snapshot/index.ts`
- [ ] `src/storage/db.ts`
- [ ] `src/storage/storage.ts`
- [x] `src/sync/index.ts` — public API (`SyncEvent.define`) is Schema-first; `payloads()` still derives zod for the remaining HTTP/OpenAPI boundary
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`
- [ ] `src/worktree/index.ts`
### Do-not-migrate
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
(it's what emits zod from Schema). Goes away only when the `.zod`
compatibility layer is no longer needed anywhere.
## Notes
- Use `@/util/effect-zod` for all Schema → Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more
valuable than converting every boundary in the same change.
- Every migrated file should leave the generated SDK output (`packages/sdk/
openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
unless the change is deliberately user-visible.

View File

@@ -0,0 +1,668 @@
# Server package extraction
Practical reference for extracting a future `packages/server` from the current `packages/opencode` monolith while `packages/core` is still being migrated to Effect.
This document is intentionally execution-oriented.
It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints.
## Goal
Create `packages/server` as the home for:
- HTTP contract definitions
- HTTP handler implementations
- OpenAPI generation
- eventual embeddable server APIs for Node apps
Do this without blocking on the full `packages/core` extraction.
## Future state
Target package layout:
- `packages/core` - all opencode services, Effect-first source of truth
- `packages/server` - opencode server, with separate contract and implementation, still producing `openapi.json`
- `packages/cli` - TUI + CLI entrypoints
- `packages/sdk` - generated from the server OpenAPI spec, may add higher-level wrappers
- `packages/plugin` - generated or semi-hand-rolled non-Effect package built from core plugin definitions
Desired user stories:
- import from `core` and build a custom agent or app-specific runtime
- import from `server` and embed the full opencode server into an existing Node app
- spawn the CLI and talk to the server through that boundary
## Current state
Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
- there is no `packages/server` workspace yet on this branch
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
This means the package split should start from an extraction path, not from greenfield package ownership.
## Structural reference
Use `anomalyco/opentunnel` as the structural reference for `packages/server`.
The important pattern there is:
- `packages/core` owns services and domain schemas
- `packages/server/src/definition/*` owns pure `HttpApi` contracts
- `packages/server/src/api/*` owns `HttpApiBuilder.group(...)` implementations and server-side middleware wiring
- `packages/server/src/index.ts` becomes the composition root only after the server package really owns runtime hosting
Relevant `opentunnel` files:
- `packages/server/src/definition/index.ts`
- `packages/server/src/definition/tunnel.ts`
- `packages/server/src/api/index.ts`
- `packages/server/src/api/tunnel.ts`
- `packages/server/src/api/client.ts`
- `packages/server/src/index.ts`
The intended direction here is the same, but the current `opencode` package split is earlier in the migration.
That means:
- we should follow the same `definition` and `api` naming
- we should keep contract and implementation as separate modules from the start
- we should postpone the runtime composition root until `packages/core` exists enough to support it cleanly
## Key decision
Start `packages/server` as a contract and implementation package only.
Do not make it the runtime host yet.
Why:
- `packages/core` does not exist yet
- the current server host still lives in `packages/opencode`
- moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight
- if `packages/server` imports services from `packages/opencode` while `packages/opencode` imports `packages/server` to host routes, we create a package cycle immediately
Short version:
1. create `packages/server`
2. move pure `HttpApi` contracts there
3. move handler factories there
4. keep `packages/opencode` as the temporary Hono host
5. merge `packages/server` OpenAPI with the legacy Hono OpenAPI during the transition
6. move server hosting later, after `packages/core` exists enough
## Dependency rule
Phase 1 rule:
- `packages/server` must not import from `packages/opencode`
Allowed in phase 1:
- `packages/opencode` imports `packages/server`
- `packages/server` accepts host-provided services, layers, or callbacks as inputs
- `packages/server` may temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet
Future rule after `packages/core` exists:
- `packages/server` imports from `packages/core`
- `packages/cli` imports from `packages/server` and `packages/core`
- `packages/opencode` shrinks or disappears as package responsibilities are fully split
## HttpApi model
Use Effect v4 `HttpApi` as the source of truth for migrated HTTP routes.
Important properties from the current `effect` / `effect-smol` model:
- `HttpApi`, `HttpApiGroup`, and `HttpApiEndpoint` are pure contract definitions
- handlers are implemented separately with `HttpApiBuilder.group(...)`
- OpenAPI can be generated from the contract alone
- auth and middleware can later be modeled with `HttpApiMiddleware.Service`
- SSE and websocket routes are not good first-wave `HttpApi` targets
This package split should preserve that separation explicitly.
Default shape for migrated routes:
- contract lives in `packages/server/src/definition/*`
- implementation lives in `packages/server/src/api/*`
- host mounting stays outside for now
## OpenAPI rule
During the transition there is still one spec artifact.
Default rule:
- `packages/server` generates OpenAPI from `HttpApi` contract
- `packages/opencode` keeps generating legacy OpenAPI from Hono routes
- the temporary exported server spec is a merged document
- `packages/sdk` continues consuming one `openapi.json`
Merge safety rules:
- fail on duplicate `path + method`
- fail on duplicate `operationId`
- prefer explicit summary, description, and operation ids on all new `HttpApi` endpoints
Practical implication:
- do not make the SDK consume two specs
- do not switch SDK generation to `packages/server` only until enough of the route surface has moved
## Package shape
Minimum viable `packages/server`:
- `src/index.ts`
- `src/definition/index.ts`
- `src/definition/api.ts`
- `src/definition/question.ts`
- `src/api/index.ts`
- `src/api/question.ts`
- `src/openapi.ts`
- `src/bridge/hono.ts`
- `src/types.ts`
Later additions, once there is enough real contract surface:
- `src/api/client.ts`
- runtime composition in `src/index.ts`
Suggested initial exports:
- `api`
- `openapi`
- `questionApi`
- `makeQuestionHandler`
Phase 1 responsibilities:
- own pure API contracts
- own handler factories for migrated slices
- own contract-generated OpenAPI
- expose host adapters needed by `packages/opencode`
Phase 1 non-goals:
- do not own `listen()`
- do not own adapter selection
- do not own global server middleware
- do not own websocket or SSE transport
- do not own process bootstrapping for CLI entrypoints
## Current source inventory
These files matter for the first phase.
Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/routes/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
Current bridged `HttpApi` slices:
- `src/server/routes/instance/httpapi/question.ts`
- `src/server/routes/instance/httpapi/permission.ts`
- `src/server/routes/instance/httpapi/provider.ts`
- `src/server/routes/instance/httpapi/config.ts`
- `src/server/routes/instance/httpapi/project.ts`
- `src/server/routes/instance/httpapi/server.ts`
Current OpenAPI flow:
- `src/server/server.ts` via `Server.openapi()`
- `src/cli/cmd/generate.ts`
- `packages/sdk/js/script/build.ts`
Current runtime and service layer:
- `src/effect/app-runtime.ts`
- `src/effect/run-service.ts`
## Ownership rules
Move first into `packages/server`:
- the experimental `question` `HttpApi` slice
- future `provider` and `config` JSON read slices
- any new `HttpApi` route groups
- transport-local OpenAPI generation for migrated routes
Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/routes/**/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
- `src/effect/run-service.ts`
- all Effect services until they move to `packages/core`
## Placeholder schema rule
`packages/core` is allowed to lag behind.
Until shared canonical schemas move to `packages/core`:
- prefer importing existing Effect Schema DTOs from current locations when practical
- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema
- if a placeholder is introduced, leave a short note so it does not become permanent
The default rule from `schema.md` still applies:
- Effect Schema owns the type
- `.zod` is compatibility only
- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape
## Host boundary rule
Until host ownership moves:
- auth stays at the outer Hono app level
- compression stays at the outer Hono app level
- CORS stays at the outer Hono app level
- instance and workspace lookup stay at the current middleware layer
- `packages/server` handlers should assume the host already provided the right request context
- do not redesign host middleware just to land the package split
This matches the current guidance in `http-api.md`:
- keep auth outside the first parallel `HttpApi` slices
- keep instance lookup outside the first parallel `HttpApi` slices
- keep the first migrations transport-focused and semantics-preserving
## Route selection rules
Good early migration targets:
- `question`
- `provider` auth read endpoint
- `config` providers read endpoint
- small read-only instance routes
Bad early migration targets:
- `session`
- `event`
- `pty`
- most `global` streaming or process-heavy routes
- anything requiring websocket upgrade handling
- anything that mixes many mutations and streaming in one file
## First vertical slice
The first slice for the package split is still the existing `question` `HttpApi` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it is JSON-only
- it has low blast radius
Use the first slice to prove:
- package boundary
- contract and implementation split
- host mounting from `packages/opencode`
- merged OpenAPI output
- test ergonomics for future slices
Do not broaden scope in the first slice.
## Incremental migration order
Use small PRs.
Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors.
### PR 1. Create `packages/server`
Scope:
- add the new workspace package
- add package manifest and tsconfig
- add empty `src/index.ts`, `src/definition/api.ts`, `src/definition/index.ts`, `src/api/index.ts`, `src/openapi.ts`, and supporting scaffolding
Rules:
- no production behavior changes
- no host server changes yet
- no imports from `packages/opencode` inside `packages/server`
- prefer `opentunnel`-style naming from the start: `definition` for contracts, `api` for implementations
Done means:
- `packages/server` typechecks
- the workspace can import it
- the package boundary is in place for follow-up PRs
### PR 2. Move the experimental question contract
Scope:
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
Rules:
- contract only in this PR
- no handler movement yet if that keeps the diff simpler
- keep operation ids and docs metadata stable
Done means:
- question contract lives in `packages/server`
- OpenAPI can be generated from contract alone
- no runtime behavior changes yet
### PR 3. Move the experimental question handler factory
Scope:
- extract the question `HttpApiBuilder.group(...)` implementation into `packages/server/src/api/question.ts`
- expose it as a factory that accepts host-provided dependencies or wiring
- add a small Hono bridge in `packages/server/src/bridge/hono.ts` if needed
Rules:
- `packages/server` must still not import from `packages/opencode`
- handler code should stay thin and service-delegating
- do not redesign the question service itself in this PR
Done means:
- `packages/server` can produce the experimental question handler
- the package still stays cycle-free
### PR 4. Mount `packages/server` question from `packages/opencode`
Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
- `/question`
- `/question/:requestID/reply`
- `/question/:requestID/reject`
Rules:
- no behavior change
- preserve existing docs path
- preserve current request and response shapes
Done means:
- existing question `HttpApi` test still passes
- runtime behavior is unchanged
- the current host server is now consuming `packages/server`
### PR 5. Merge legacy and contract OpenAPI
Scope:
- keep `Server.openapi()` as the temporary spec entrypoint
- generate legacy Hono spec
- generate `packages/server` contract spec
- merge them into one document
- keep `cli/cmd/generate.ts` and `packages/sdk/js/script/build.ts` consuming one spec
Rules:
- fail loudly on duplicate `path + method`
- fail loudly on duplicate `operationId`
- do not silently overwrite one source with the other
Done means:
- one merged spec is produced
- migrated question paths can come from `packages/server`
- existing SDK generation path still works
### PR 6. Add merged OpenAPI coverage
Scope:
- add one test for merged OpenAPI
- assert both a legacy Hono route and a migrated `HttpApi` route exist
Rules:
- test the merged document, not just the `packages/server` contract spec in isolation
- pick one stable legacy route and one stable migrated route
Done means:
- the merged-spec path is covered
- future route migrations have a guardrail
### PR 7. Migrate `GET /provider/auth`
Scope:
- add `GET /provider/auth` as the next `HttpApi` slice in `packages/server`
- mount it in parallel from `packages/opencode`
Why this route:
- JSON-only
- simple service delegation
- small response shape
- already listed as the best next `provider` candidate in `http-api.md`
Done means:
- route works through the current host
- route appears in merged OpenAPI
- no semantic change to provider auth behavior
### PR 8. Migrate `GET /config/providers`
Scope:
- add `GET /config/providers` as a `HttpApi` slice in `packages/server`
- mount it in parallel from `packages/opencode`
Why this route:
- JSON-only
- read-only
- low transport complexity
- already listed as the best next `config` candidate in `http-api.md`
Done means:
- route works unchanged
- route appears in merged OpenAPI
### PR 9+. Migrate small read-only instance routes
Candidate order:
1. `GET /path`
2. `GET /vcs`
3. `GET /vcs/diff`
4. `GET /command`
5. `GET /agent`
6. `GET /skill`
Rules:
- one or two endpoints per PR
- prefer read-only routes first
- keep outer middleware unchanged
- keep business logic in the existing service layer
Done means for each PR:
- contract lives in `packages/server`
- handler lives in `packages/server`
- route is mounted from the current host
- route appears in merged OpenAPI
- behavior remains unchanged
### Later PR. Move host ownership into `packages/server`
Only start this after there is enough `packages/core` surface to depend on directly.
Scope:
- move server composition into `packages/server`
- add embeddable APIs such as `createServer(...)`, `listen(...)`, or `createApp(...)`
- move adapter selection and server startup out of `packages/opencode`
Rules:
- do not start this while `packages/server` still depends on `packages/opencode`
- do not mix this with route migration PRs
Done means:
- `packages/server` can be embedded in another Node app
- `packages/cli` can depend on `packages/server`
- host logic no longer lives in `packages/opencode`
## PR sizing rule
Every migration PR should satisfy all of these:
- one route group or one to two endpoints
- no unrelated service refactor
- no auth redesign
- no middleware redesign
- OpenAPI updated
- at least one route test or spec test added or updated
## Done means for a migrated route group
A route group migration is complete only when:
1. the `HttpApi` contract lives in `packages/server`
2. handler implementation lives in `packages/server`
3. the route is mounted from the current host in `packages/opencode`
4. the route appears in merged OpenAPI
5. request and response schemas are Effect Schema-first or clearly temporary placeholders
6. existing behavior remains unchanged
7. the route has straightforward test coverage
## Validation expectations
For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
- the relevant server / route coverage for the migrated slice
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
## Main risks
### Package cycle
This is the biggest risk.
Bad state:
- `packages/server` imports services or runtime from `packages/opencode`
- `packages/opencode` imports route definitions or handlers from `packages/server`
Avoid by:
- keeping phase-1 `packages/server` free of `packages/opencode` imports
- using factories and host-provided wiring instead of direct service imports
### Spec drift
During the transition there are two route-definition sources.
Avoid by:
- one merged spec
- collision checks
- explicit `operationId`s
- merged OpenAPI tests
### Middleware mismatch
Current auth, compression, CORS, and instance selection are Hono-centered.
Avoid by:
- leaving them where they are during the first wave
- not trying to solve `HttpApiMiddleware.Service` globally in the package-split PRs
### Core lag
`packages/core` will not be ready everywhere.
Avoid by:
- allowing small transport-local placeholder schemas where necessary
- keeping those placeholders clearly temporary
- not blocking the server extraction on full schema movement
### Scope creep
The first vertical slice is easy to overload.
Avoid by:
- proving the package boundary first
- not mixing package creation, route migration, host redesign, and core extraction in the same change
## Non-goals for the first wave
- do not replace all Hono routes at once
- do not migrate SSE or websocket routes first
- do not redesign auth
- do not redesign instance lookup
- do not wait for full `packages/core` before starting `packages/server`
- do not change SDK generation to consume multiple specs
## Checklist
- [x] create `packages/server`
- [x] add package-level exports for contract and OpenAPI
- [ ] extract `question` contract into `packages/server`
- [ ] extract `question` handler factory into `packages/server`
- [ ] mount `question` from `packages/opencode`
- [ ] merge legacy and contract OpenAPI into one document
- [ ] add merged-spec coverage
- [ ] migrate `GET /provider/auth`
- [ ] migrate `GET /config/providers`
- [ ] migrate small read-only instance routes one or two at a time
- [ ] move host ownership into `packages/server` only after `packages/core` is ready enough
- [ ] split `packages/cli` after server and core boundaries are stable
## Rule of thumb
The fastest correct path is:
1. establish `packages/server` as the contract-first boundary
2. keep `packages/opencode` as the temporary host
3. migrate a few safe JSON routes
4. keep one merged OpenAPI document
5. move actual host ownership only after `packages/core` can support it cleanly
If a proposed PR would make `packages/server` import from `packages/opencode`, stop and restructure the boundary first.

View File

@@ -0,0 +1,92 @@
# 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 `AppFileSystem`, `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] `codesearch.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:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `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
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
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`
- `codesearch.ts`
- `websearch.ts`
- `edit.ts`
## Filesystem notes
Current raw fs users that still appear relevant here:
- `tool/read.ts``fs.createReadStream`, `readline`
- `file/ripgrep.ts``fs/promises`
- `patch/index.ts``fs`, `fs/promises`