Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton 1d0b1b3518 refactor(core): move wake scope into coordinator 2026-08-14 23:31:57 -04:00
Kit Langton eb2c80fa23 fix(core): preserve scoped interrupt wakes 2026-08-14 21:12:21 -04:00
Kit Langton 3f2031a325 fix(core): keep queued work parked after interrupt 2026-08-14 21:00:38 -04:00
25 changed files with 1645 additions and 628 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode-ai/plugin": patch
---
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
+223 -63
View File
@@ -1,112 +1,272 @@
# Contributing to OpenCode
The changes most likely to be accepted are:
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged:
- Bug fixes
- Additional LSPs and formatters
- LLM performance improvements
- Environment-specific fixes
- Additional LSPs / Formatters
- Improvements to LLM performance
- Support for new providers
- Fixes for environment-specific quirks
- Missing standard behavior
- Documentation improvements
UI and core product features require design review before implementation. If you are unsure whether a change fits, ask a maintainer or choose an issue labeled [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted), [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22), [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug), or [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22).
However, any UI or core product feature must go through a design review with the core team before implementation.
Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
If you are unsure if a PR would be accepted, feel free to ask a maintainer or look for issues with any of the following labels:
- [`help wanted`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/anomalyco/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/anomalyco/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
## Adding Providers
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
## Adding New Providers
## Development
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
OpenCode requires Bun 1.3 or newer. From the repository root:
## Developing OpenCode
- Requirements: Bun 1.3+
- Install dependencies and start the dev server from the repo root:
```bash
bun install
bun dev
```
### Running against a different directory
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
```bash
bun install
bun dev [directory]
bun dev <directory>
```
`bun dev` runs the V2 CLI and TUI. Pass a directory to open another project, or `.` to open this repository.
To test a development TUI against your installed OpenCode V2 background service and live sessions:
To run OpenCode in the root of the opencode repo itself:
```bash
bun run dev:live [directory]
bun dev .
```
For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
### Building a "localcode"
To compile a standalone executable:
```bash
bun dev serve --port 4096
bun run dev:web
bun run dev:desktop
bun run dev:www
./packages/opencode/script/build.ts --single
```
### Packages
- `packages/schema`: shared wire and storage contracts
- `packages/core`: domain behavior and persistence
- `packages/protocol`: public API definitions
- `packages/server`: HTTP server and runtime composition
- `packages/client`: generated TypeScript clients
- `packages/cli`: command-line entrypoint and service lifecycle
- `packages/tui`: terminal interface
- `packages/app`: shared web interface
- `packages/desktop`: Electron desktop application
- `packages/plugin`: plugin API
### Verification
Run typechecks, and tests where defined, from the affected package rather than the repository root:
Then run it with:
```bash
cd packages/core
bun run test
bun typecheck
./packages/opencode/dist/opencode-<platform>/bin/opencode
```
Follow package-specific instructions in nearby `AGENTS.md` files. After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`; never edit generated client files directly.
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
Follow the repository [style guide](./AGENTS.md).
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
## Pull Requests
### Understanding bun dev vs opencode
### Link Issues When Required
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface:
Bug fixes, chores, and tests must reference an existing issue. Documentation, refactor, and feature PRs are exempt from the automated linked-issue check. When required, use `Fixes #123` or `Closes #123` in the PR description.
```bash
# Development (from project root)
bun dev --help # Show all available commands
bun dev serve # Start headless API server
bun dev web # Start server + open web interface
bun dev <directory> # Start TUI in specific directory
Before implementing new functionality, open a feature request describing the problem, why it belongs in OpenCode, and your proposed approach if you have one. Wait for design approval before opening the implementation PR.
# Production
opencode --help # Show all available commands
opencode serve # Start headless API server
opencode web # Start server + open web interface
opencode <directory> # Start TUI in specific directory
```
Base branches on `v2`, not `dev`, and complete the provided pull request template.
### Running the API Server
### Keep It Focused
To start the OpenCode headless API server:
- Keep PRs small and focused.
- Explain the problem and why the change fixes it.
- Check whether the functionality already exists.
- For UI changes, include before-and-after screenshots or video.
- For logic changes, explain what you tested and how a reviewer can verify it.
```bash
bun dev serve
```
### Keep It Brief
This starts the headless server on port 4096 by default. You can specify a different port:
Long, AI-generated PR descriptions and issues may be ignored. Write a short explanation in your own words. If the change cannot be explained briefly, the PR may be too large.
```bash
bun dev serve --port 8080
```
### Use Conventional Titles
### Running the Web App
Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
To test UI changes during development:
1. **First, start the OpenCode server** (see [Running the API Server](#running-the-api-server) section above)
2. **Then run the web app:**
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.
### Running the Desktop App
The desktop app is an Electron application that wraps the web UI.
To run the desktop app in development:
```bash
bun run --cwd packages/desktop dev
```
To create a production build and package the app:
```bash
bun run --cwd packages/desktop build
bun run --cwd packages/desktop package
```
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
Please try to follow the [style guide](./AGENTS.md)
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
Caveats:
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --cwd packages/opencode --conditions=browser ./src/index.ts`
Other tips and tricks:
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
#### VSCode Setup
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
Some debug methods that can be problematic:
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
### Issue First Policy
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Explain the issue and why your change fixes it
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples:
- `docs: update contributing guide`
- `fix(tui): restore scroll position`
- `feat(app): add workspace search`
- `docs: update contributing guidelines`
- `fix: resolve crash on startup`
- `feat: add dark mode support`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
## Issues
### Style Preferences
Bug reports and feature requests must use their issue templates. Blank issues are not allowed; ask support and how-to questions in the [Discord community](https://discord.gg/opencode).
These are not strictly enforced, they are just general guidelines:
Automated checks flag missing templates, placeholder text, AI-generated walls of text, and missing meaningful content. You have two hours to correct a flagged issue before it closes automatically. Ask a maintainer if an issue was flagged incorrectly.
- **Functions:** Keep logic within a single function unless breaking it out adds clear reuse or composition benefits.
- **Destructuring:** Do not do unnecessary destructuring of variables.
- **Control flow:** Avoid `else` statements.
- **Error handling:** Prefer `.catch(...)` instead of `try`/`catch` when possible.
- **Types:** Reach for precise types and avoid `any`.
- **Variables:** Stick to immutable patterns and avoid `let`.
- **Naming:** Choose concise single-word identifiers when they remain descriptive.
- **Runtime APIs:** Use Bun helpers such as `Bun.file()` when they fit the use case.
## Feature Requests
For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly.
## Issue Requirements
All issues **must** use one of our issue templates:
- **Bug report** — for reporting bugs (requires a description)
- **Feature request** — for suggesting enhancements (requires verification checkbox and description)
- **Question** — for asking questions (requires the question)
Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed.
Issues may be flagged for:
- Not using a template
- Required fields left empty or filled with placeholder text
- AI-generated walls of text
- Missing meaningful content
If you believe your issue was incorrectly flagged, let a maintainer know.
-1
View File
@@ -568,7 +568,6 @@
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@standard-schema/spec": "catalog:",
+1 -1
View File
@@ -41,8 +41,8 @@
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"ws": "8.21.0"
},
"devDependencies": {
+394 -1
View File
@@ -1,3 +1,396 @@
export * as PluginPromise from "./promise.js"
export { fromPromise } from "@opencode-ai/plugin/promise/adapter"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
import type { Info } from "@opencode-ai/plugin/promise/tool"
import { Agent } from "@opencode-ai/schema/agent"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { Workspace } from "@opencode-ai/schema/workspace"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { DateTime, Effect, Scope, Stream } from "effect"
import { Tool } from "../tool.js"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
effect: (host) =>
Effect.gen(function* () {
const scope = yield* Scope.Scope
const context = yield* Effect.context<Scope.Scope>()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect).then(wire)
const transform =
<Draft>(domain: {
transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) =>
(callback: (draft: Draft) => void) =>
register(
domain.transform((draft) => {
callback(draft)
}),
)
const context2: Context = {
app: host.app,
options: host.options,
agent: {
get: (input) => run(host.agent.get({ ...input, agentID: Agent.ID.make(input.agentID) })),
list: (input) => run(host.agent.list(input)),
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
aisdk: {
hook: (name, callback) =>
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
provider: {
list: (input) => run(host.catalog.provider.list(input)),
get: (input) =>
run(host.catalog.provider.get({ ...input, providerID: Provider.ID.make(input.providerID) })),
},
model: {
list: (input) => run(host.catalog.model.list(input)),
default: (input) =>
run(host.catalog.model.default(input)).then((result) => ({ ...result, data: result.data ?? null })),
},
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
command: {
list: (input) => run(host.command.list(input)),
transform: transform(host.command),
reload: () => run(host.command.reload()),
},
event: {
subscribe: () => Stream.toAsyncIterable(host.event.subscribe().pipe(Stream.map(wireEvent))),
},
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) =>
run(host.integration.get({ ...input, integrationID: Integration.ID.make(input.integrationID) })).then(
(result) => ({ ...result, data: result.data ?? null }),
),
connect: {
key: (input) =>
run(
host.integration.connect.key({ ...input, integrationID: Integration.ID.make(input.integrationID) }),
),
},
oauth: {
connect: (input) =>
run(
host.integration.oauth.connect({
...input,
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
}),
),
status: (input) =>
run(
host.integration.oauth.status({
...input,
integrationID: Integration.ID.make(input.integrationID),
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
complete: (input) =>
run(
host.integration.oauth.complete({
...input,
integrationID: Integration.ID.make(input.integrationID),
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
cancel: (input) =>
run(
host.integration.oauth.cancel({
...input,
integrationID: Integration.ID.make(input.integrationID),
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
},
command: {
connect: (input) =>
run(
host.integration.command.connect({
...input,
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
}),
),
status: (input) =>
run(
host.integration.command.status({
...input,
integrationID: Integration.ID.make(input.integrationID),
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
cancel: (input) =>
run(
host.integration.command.cancel({
...input,
integrationID: Integration.ID.make(input.integrationID),
attemptID: Integration.AttemptID.make(input.attemptID),
}),
),
},
transform: (callback) =>
register(
host.integration.transform((draft) =>
callback({
list: draft.list,
get: draft.get,
update: draft.update,
remove: draft.remove,
method: {
list: draft.method.list,
update: (input) => {
if (!("authorize" in input)) return draft.method.update(input)
const refresh = input.refresh
draft.method.update({
...input,
authorize: (answer) =>
Effect.promise(() => input.authorize(answer)).pipe(
Effect.map((authorization) =>
authorization.mode === "auto"
? {
...authorization,
callback: Effect.promise(() => authorization.callback),
}
: {
...authorization,
callback: (code) => Effect.promise(() => authorization.callback(code)),
},
),
),
refresh:
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
},
}),
),
),
reload: () => run(host.integration.reload()),
connection: {
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
},
},
plugin: {
list: (input) => run(host.plugin.list(input)),
},
reference: {
list: (input) => run(host.reference.list(input)),
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
skill: {
list: (input) => run(host.skill.list(input)),
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
tool: {
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
add: (tool: Info) =>
draft.add({
...tool,
execute: (input, context) => executePromiseTool(tool, input, context),
}),
}),
),
),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
providers: (input) => run(host.websearch.providers(input)),
query: (input) =>
run(
host.websearch.query({
...input,
providerID: input.providerID === undefined ? undefined : WebSearch.ID.make(input.providerID),
}),
),
reload: () => run(host.websearch.reload()),
transform: (callback) =>
register(
host.websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
}),
default: draft.default,
})
}),
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) =>
run(
host.session.create(
input === undefined
? undefined
: {
id: input.id == null ? undefined : Session.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
location:
input.location == null
? undefined
: Location.Ref.make({
directory: AbsolutePath.make(input.location.directory),
workspaceID:
input.location.workspaceID === undefined
? undefined
: Workspace.ID.make(input.location.workspaceID),
}),
},
),
),
get: (input) => run(host.session.get({ sessionID: Session.ID.make(input.sessionID) })),
prompt: (input) =>
run(
host.session.prompt({
...input,
sessionID: Session.ID.make(input.sessionID),
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
delivery: input.delivery ?? undefined,
resume: input.resume ?? undefined,
}),
),
generate: (input) =>
run(host.session.generate({ sessionID: Session.ID.make(input.sessionID), prompt: input.prompt })),
command: (input) =>
run(
host.session.command({
...input,
sessionID: Session.ID.make(input.sessionID),
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
model: input.model == null ? undefined : model(input.model),
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
arguments: input.arguments ?? undefined,
delivery: input.delivery ?? undefined,
resume: input.resume ?? undefined,
}),
),
synthetic: (input) =>
run(
host.session.synthetic({
...input,
sessionID: Session.ID.make(input.sessionID),
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
description: input.description ?? undefined,
delivery: input.delivery ?? undefined,
resume: input.resume ?? undefined,
}),
),
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
},
shell: {
hook: (name, callback) =>
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
if (!cleanup) return
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
}),
})
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
function model(input: { readonly id: string; readonly providerID: string; readonly variant?: string }) {
return Model.Ref.make({
id: Model.ID.make(input.id),
providerID: Provider.ID.make(input.providerID),
variant: input.variant === undefined ? undefined : Model.VariantID.make(input.variant),
})
}
type Wire<Value> = unknown extends Value
? JsonValue
: Value extends string | number | boolean | bigint | symbol | null | undefined
? Value
: Value extends DateTime.DateTime
? number
: Value extends readonly [infer Head, ...infer Tail]
? [Wire<Head>, ...WireTuple<Tail>]
: Value extends ReadonlyArray<infer Item>
? Array<Wire<Item>>
: Value extends object
? { -readonly [Key in keyof Value]: Wire<Value[Key]> }
: Value
type WireTuple<Value extends ReadonlyArray<unknown>> = {
-readonly [Key in keyof Value]: Wire<Value[Key]>
}
function wire<Value>(value: Value): Wire<Value>
function wire(value: unknown): unknown {
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
if (Array.isArray(value)) return value.map(wire)
if (typeof value !== "object" || value === null) return value
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, wire(item)]))
}
function wireEvent(value: unknown): PromiseEvent
function wireEvent(value: unknown): unknown {
return wire(value)
}
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
)
+5 -3
View File
@@ -789,7 +789,10 @@ const layer = Layer.effect(
return false
}),
)
if (recovered) return
if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID)
}),
compact: Effect.fn("Session.compact")(function* (input) {
@@ -875,8 +878,7 @@ const layer = Layer.effect(
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
yield* execution.interrupt(sessionID, options)
}),
),
),
+22 -11
View File
@@ -1,7 +1,8 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js"
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -19,8 +21,10 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Wakes only an active execution, preserving its current input eligibility. */
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
sessionID: SessionSchema.ID,
force: boolean,
continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation }),
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
),
)
if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation)
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: (sessionID, force) => drain(sessionID, force),
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
@@ -127,16 +133,20 @@ export const layer = Layer.effect(
}),
),
})
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({
active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
interrupt: (sessionID, options) =>
coordinator.interrupt(
sessionID,
"user",
options?.continue
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
: undefined,
),
resume: coordinator.run,
wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -155,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
+8
View File
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
return row ? fromRow(row) : undefined
})
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
})
/**
* Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode.
+83 -23
View File
@@ -1,6 +1,7 @@
export * as SessionRunCoordinator from "./run-coordinator.js"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import type { Promotable } from "./inbox.js"
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E, Reason = never> {
@@ -9,26 +10,41 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
readonly wakeActive: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
export type Request = Promotable
/**
* One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it, and the execution loop drains again instead of ending. The doorbell
* closes the gap between a drain's last eligibility check and the idle transition, since
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
* execution rings it with its eligibility request, and the execution loop drains again
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit.
*/
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
request: Request
pendingWake?: Request
stopping: boolean
interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
}
/**
@@ -43,7 +59,7 @@ type Execution<E, Reason> = {
* ```
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
@@ -55,23 +71,26 @@ export const make = <Key, E, Reason = never>(options: {
Effect.gen(function* () {
const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const merge = (left: Request, right: Request): Request =>
left === "input" || right === "input" ? "input" : "steer"
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.request = execution.pendingWake
execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}),
),
)
const start = (key: Key, force: boolean) => {
const start = (key: Key, force: boolean, request: Request) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
request,
stopping: false,
}
executions.set(key, execution)
@@ -87,7 +106,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.onExit((exit) => finish(key, execution, exit)),
Effect.exit,
Effect.asVoid,
),
@@ -97,12 +116,22 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, continuation: boolean) => {
if (continuation && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
return execution.continuation.when.pipe(
Effect.flatMap((ready) =>
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
),
)
}
const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => {
const execution = executions.get(key)
@@ -111,26 +140,57 @@ export const make = <Key, E, Reason = never>(options: {
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
return Deferred.await(start(key, true).done)
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key) =>
const wake = (key: Key, request: Request = "input") =>
Effect.sync(() => {
const execution = executions.get(key)
if (execution !== undefined) {
execution.pendingWake = true
if (execution.stopping) {
if (execution.continuation) execution.continuation.signaled = true
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
return
}
execution.pendingWake = execution.pendingWake ? merge(execution.pendingWake, request) : request
return
}
start(key, false)
start(key, false, request)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
const wakeActive = (key: Key) =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void
return execution ? wake(key, execution.request) : Effect.void
})
const interrupt = (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
if (execution.stopping) {
if (options?.continue)
execution.continuation = {
...options.continue,
signaled: execution.continuation?.signaled ?? false,
}
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution.owner === undefined) {
if (!options?.continue) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
execution.stopping = true
execution.pendingWake = false
execution.pendingWake = undefined
execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner)
})
@@ -143,5 +203,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
})
@@ -3,6 +3,7 @@ export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js"
@@ -29,6 +30,8 @@ export interface Interface {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
/** "steer" settles the active intent without promoting queued next-turn work. */
readonly promotable?: Promotable
}) => Effect.Effect<DrainResult, RunError>
}
+14 -12
View File
@@ -128,22 +128,25 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
let force = input.force
let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID)) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation)
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false
continuation = undefined
}
@@ -156,13 +159,14 @@ const layer = Layer.effect(
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation?: Continuation,
initialPromotable: SessionInbox.Promotable = continuation ? "steer" : "input",
) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
let promotable = initialPromotable
let step = continuation?.step ?? 1
let next = continuation
while (true) {
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined
@@ -515,14 +519,14 @@ const layer = Layer.effect(
/** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
@@ -564,9 +568,7 @@ const layer = Layer.effect(
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
-88
View File
@@ -4,7 +4,6 @@ import { DateTime, Effect, Schema } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Model } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -15,10 +14,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { define } from "@opencode-ai/plugin/promise/plugin"
import { Money } from "@opencode-ai/schema/money"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -27,53 +23,6 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("adapts session creation through the protocol schema", () =>
Effect.gen(function* () {
let seen: unknown
const host = testHost({
session: {
create: (input) => {
seen = input
return Effect.succeed(
Session.Info.make({
id: Session.ID.make("ses_protocol_adapter"),
projectID: Project.ID.make("project"),
cost: Money.USD.make(0),
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
time: { created: DateTime.makeUnsafe(10), updated: DateTime.makeUnsafe(20) },
title: input?.title,
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
}),
)
},
},
})
yield* PluginPromise.fromPromise(
define({
id: "promise-session-create",
setup: async (ctx) => {
await expect(Reflect.apply(ctx.session.create, undefined, [{ title: 42 }])).rejects.toBeDefined()
const result = await ctx.session.create({
id: null,
title: "Promise title",
agent: null,
model: null,
location: null,
})
expect(result).toMatchObject({
id: "ses_protocol_adapter",
title: "Promise title",
time: { created: 10, updated: 20 },
})
},
}),
).effect(host)
expect(seen).toEqual({ title: "Promise title" })
}),
)
it.effect("forwards transient session generation", () =>
Effect.gen(function* () {
const host = testHost({
@@ -95,42 +44,6 @@ describe("fromPromise", () => {
}),
)
it.effect("preserves no-content and rejected Promise behavior", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const host = testHost({
session: {
interrupt: (input) => {
if (input.sessionID === Session.ID.make("ses_failure")) {
return Effect.fail(new Error("interrupt failed"))
}
expect(input.continue).toBe(true)
return Effect.void
},
rename: (input) => Effect.sync(() => seen.push(input)),
wait: (input) => Effect.sync(() => seen.push(input)),
},
})
yield* PluginPromise.fromPromise(
define({
id: "promise-session-interrupt",
setup: async (ctx) => {
expect(await ctx.session.interrupt({ sessionID: "ses_success", continue: true })).toBeUndefined()
await expect(ctx.session.interrupt({ sessionID: "ses_failure" })).rejects.toThrow("interrupt failed")
expect(await ctx.session.rename({ sessionID: "ses_success", title: "Renamed" })).toBeUndefined()
expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined()
},
}),
).effect(host)
expect(seen).toEqual([
{ sessionID: Session.ID.make("ses_success"), title: "Renamed" },
{ sessionID: Session.ID.make("ses_success") },
])
}),
)
it.effect("forwards synthetic session input", () =>
Effect.gen(function* () {
const input = {
@@ -201,7 +114,6 @@ describe("fromPromise", () => {
ctx.skill.list(),
])
seen.push(...results.map((result) => result.location.directory))
expect((await ctx.integration.get({ integrationID: "missing" })).data).toBeNull()
},
})
+7 -17
View File
@@ -31,6 +31,7 @@ import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
const interruptContinuations: Array<boolean | undefined> = []
const wakeCalls: Session.ID[] = []
const activeSessions = new Set<Session.ID>()
const execution = Layer.succeed(
@@ -41,14 +42,16 @@ const execution = Layer.succeed(
Effect.sync(() => {
executionCalls.push(sessionID)
}),
interrupt: (sessionID) =>
interrupt: (sessionID, options) =>
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
}),
wake: (sessionID) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
}),
wakeActive: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
@@ -177,31 +180,18 @@ describe("Session.prompt", () => {
}),
)
it.effect("continues after interruption when pending work remains", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
interruptCalls.length = 0
wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("does not continue after interruption without pending work", () =>
it.effect("forwards interrupt continuation policy", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
interruptCalls.length = 0
interruptContinuations.length = 0
wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID])
expect(interruptContinuations).toEqual([true])
expect(wakeCalls).toEqual([])
}),
)
@@ -269,6 +269,35 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("replaces a settlement-window wake with a steer continuation", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) => Effect.sync(() => requests.push(request)),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(settling)
yield* coordinator.wake("session", "input")
const interrupted = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(true) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("interrupts active execution and clears its pending wake", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -342,6 +371,193 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("coalesces drain requests with input taking precedence", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["steer", "input"])
}),
),
)
it.effect("does not carry a completed input request into a steer drain", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("an active wake inherits scope without starting idle work", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wakeActive("session")
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wakeActive("session")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["steer", "steer"])
}),
),
)
it.effect("coalesces overlapping interrupt continuations into one steer successor", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } }
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("a continuing interrupt replaces a cleanup-era input wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
const plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
yield* coordinator.wake("session", "input")
const continuing = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("does not start a conditional continuation without eligible work", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.sync(() => requests.push(request)).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input"])
}),
),
)
it.effect("starts a resume registered during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -126,7 +126,8 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle,
})
}),
+20 -1
View File
@@ -413,7 +413,8 @@ const execution = Layer.effect(
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -3088,6 +3089,24 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("stops a steer-scoped drain before queued input", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
yield* TestLLM.push(TestLLM.stop())
const runner = yield* SessionRunner.Service
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toEqual(["Steer now"])
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
}),
)
it.effect("promotes queued input after steering continuation ends", () =>
Effect.gen(function* () {
const session = yield* setup
+1
View File
@@ -115,6 +115,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
})
+1
View File
@@ -86,6 +86,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
})
-1
View File
@@ -23,7 +23,6 @@
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@standard-schema/spec": "catalog:",
-324
View File
@@ -1,324 +0,0 @@
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, SchemaAST, Scope, Stream } from "effect"
import { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"
import { define } from "../effect/plugin.js"
import type { Context, Plugin } from "./plugin.js"
import type { Info } from "./tool.js"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
type PromiseEvent = ReturnType<Context["event"]["subscribe"]> extends AsyncIterable<infer Event> ? Event : never
interface CompiledEndpoint {
readonly decode: ReadonlyArray<(input: unknown) => Effect.Effect<unknown, Schema.SchemaError>>
readonly encode: (output: unknown) => Effect.Effect<unknown, Schema.SchemaError>
readonly noContent: boolean
}
const compiledEndpoints = new WeakMap<object, CompiledEndpoint>()
function compileEndpoint(endpoint: HttpApiEndpoint.Top) {
const cached = compiledEndpoints.get(endpoint)
if (cached) return cached
const payloadSchemas = Array.from(endpoint.payload.values()).flatMap(({ schemas }) => schemas)
const successSchemas = Array.from(endpoint.success)
if (payloadSchemas.length > 1 || successSchemas.length > 1) {
throw new Error(`Unsupported API schema cardinality: ${endpoint.identifier}`)
}
const inputs = [
endpoint.params,
endpoint.query === undefined ? undefined : Schema.toType(endpoint.query),
endpoint.headers,
...payloadSchemas,
].filter((schema): schema is Schema.Top => schema !== undefined) as Array<RuntimeSchema>
const success = (successSchemas[0] ?? HttpApiSchema.NoContent) as RuntimeSchema
const noContent = HttpApiSchema.isNoContent(success.ast)
const type = Schema.toType(success).ast
const data = SchemaAST.isObjects(success.ast)
? success.ast.propertySignatures.find((property) => property.name === "data")
: undefined
const output =
!noContent &&
SchemaAST.isObjects(type) &&
type.indexSignatures.length === 0 &&
type.propertySignatures.length === 1 &&
type.propertySignatures[0]?.name === "data" &&
data !== undefined
? (Schema.make<Schema.Top>(data.type) as RuntimeSchema)
: success
const compiled = {
decode: inputs.map((schema) => Schema.decodeUnknownEffect(schema)),
encode: Schema.encodeUnknownEffect(output),
noContent,
} satisfies CompiledEndpoint
compiledEndpoints.set(endpoint, compiled)
return compiled
}
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
* loader (`Plugin` / `PluginSupervisor`) can run it unchanged.
*
* Hook registrations created during the async `setup` attach to the plugin's
* scope, so unloading the plugin disposes them. The captured fiber context
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
Promise.all([import("@opencode-ai/protocol/client"), import("@opencode-ai/protocol/groups/event")]),
)
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
const ProviderEndpoints = ClientApi.groups["server.provider"].endpoints
const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const scope = yield* Scope.Scope
const context = yield* Effect.context<Scope.Scope>()
// Run a hook registration on the plugin scope and resolve once it is registered.
const register = (effect: Effect.Effect<HostRegistration, never, Scope.Scope>): Promise<Registration> =>
Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
const adaptApiMethod = <PromiseMethod>(
endpoint: HttpApiEndpoint.Top,
method: (input: never) => Effect.Effect<unknown, unknown>,
) => {
const compiled = compileEndpoint(endpoint)
return ((input?: unknown) =>
Effect.gen(function* () {
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
const result = yield* method(Object.assign({}, ...decoded) as never)
if (compiled.noContent) return undefined
return yield* compiled.encode(result)
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
}
const transform =
<Draft>(domain: {
transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) =>
(callback: (draft: Draft) => void) =>
register(
domain.transform((draft) => {
callback(draft)
}),
)
const context2: Context = {
app: host.app,
options: host.options,
agent: {
get: adaptApiMethod(AgentEndpoints["agent.get"], host.agent.get),
list: adaptApiMethod(AgentEndpoints["agent.list"], host.agent.list),
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
aisdk: {
hook: (name, callback) =>
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
provider: {
list: adaptApiMethod(ProviderEndpoints["provider.list"], host.catalog.provider.list),
get: adaptApiMethod(ProviderEndpoints["provider.get"], host.catalog.provider.get),
},
model: {
list: adaptApiMethod(ModelEndpoints["model.list"], host.catalog.model.list),
default: adaptApiMethod(ModelEndpoints["model.default"], host.catalog.model.default),
},
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
command: {
list: adaptApiMethod(CommandEndpoints["command.list"], host.command.list),
transform: transform(host.command),
reload: () => run(host.command.reload()),
},
event: {
subscribe: () =>
Stream.toAsyncIterable(
host.event.subscribe().pipe(
Stream.mapEffect((event) => Schema.encodeUnknownEffect(OpenCodeEvent)(event)),
Stream.map((event) => event as unknown as PromiseEvent),
),
),
},
integration: {
list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list),
get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get),
connect: {
key: adaptApiMethod(IntegrationEndpoints["integration.connect.key"], host.integration.connect.key),
},
oauth: {
connect: adaptApiMethod(
IntegrationEndpoints["integration.oauth.connect"],
host.integration.oauth.connect,
),
status: adaptApiMethod(IntegrationEndpoints["integration.oauth.status"], host.integration.oauth.status),
complete: adaptApiMethod(
IntegrationEndpoints["integration.oauth.complete"],
host.integration.oauth.complete,
),
cancel: adaptApiMethod(IntegrationEndpoints["integration.oauth.cancel"], host.integration.oauth.cancel),
},
command: {
connect: adaptApiMethod(
IntegrationEndpoints["integration.command.connect"],
host.integration.command.connect,
),
status: adaptApiMethod(
IntegrationEndpoints["integration.command.status"],
host.integration.command.status,
),
cancel: adaptApiMethod(
IntegrationEndpoints["integration.command.cancel"],
host.integration.command.cancel,
),
},
transform: (callback) =>
register(
host.integration.transform((draft) =>
callback({
list: draft.list,
get: draft.get,
update: draft.update,
remove: draft.remove,
method: {
list: draft.method.list,
update: (input) => {
if (!("authorize" in input)) return draft.method.update(input)
const refresh = input.refresh
draft.method.update({
...input,
authorize: (answer) =>
Effect.promise(() => input.authorize(answer)).pipe(
Effect.map((authorization) =>
authorization.mode === "auto"
? {
...authorization,
callback: Effect.promise(() => authorization.callback),
}
: {
...authorization,
callback: (code) => Effect.promise(() => authorization.callback(code)),
},
),
),
refresh:
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
},
}),
),
),
reload: () => run(host.integration.reload()),
connection: {
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
},
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
},
reference: {
list: adaptApiMethod(ReferenceEndpoints["reference.list"], host.reference.list),
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
skill: {
list: adaptApiMethod(SkillEndpoints["skill.list"], host.skill.list),
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
tool: {
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
add: (tool: Info) =>
draft.add({
...tool,
execute: (input, context) => executePromiseTool(tool, input, context),
}),
}),
),
),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
websearch: {
providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query),
reload: () => run(host.websearch.reload()),
transform: (callback) =>
register(
host.websearch.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
execute: (input) => attempt((signal) => definition.execute(input, { signal })),
}),
default: draft.default,
})
}),
),
},
session: {
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
},
shell: {
hook: (name, callback) =>
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
if (!cleanup) return
yield* Effect.addFinalizer(() => Effect.promise(() => Promise.resolve(cleanup())))
}),
})
}
function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause })
}
type RuntimeSchema = Schema.Codec<unknown, unknown>
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
Effect.promise(() =>
tool.execute(input, {
...context,
progress: (update) => Effect.runPromise(context.progress(update)),
}),
)
+1 -1
View File
@@ -38,7 +38,7 @@ export interface SessionHooks {
export type SessionDomain = Pick<
SessionApi,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
> & {
readonly hook: Hooks<SessionHooks>
}
+1 -1
View File
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
identifier: "v2.session.interrupt",
summary: "Interrupt session execution",
description:
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
}),
),
)
+2 -12
View File
@@ -345,7 +345,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
let didDrag = false
let addPressed = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
@@ -761,8 +760,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseDown={(event: MouseEvent) => {
didDrag = false
setDragging(undefined)
addPressed = event.button !== RIGHT_MOUSE_BUTTON
if (addPressed) return
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
@@ -771,11 +769,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseUp={(event: MouseEvent) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!addPressed) return
addPressed = false
if (!newTab()) tabs.add?.()
}}
onMouseDragEnd={() => (addPressed = false)}
>
<text
width={2}
@@ -842,7 +837,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
let didDrag = false
let addPressed = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const hueStep = () => (mode() === "light" ? 800 : 200)
@@ -1209,8 +1203,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseDown={(event) => {
didDrag = false
setDragging(undefined)
addPressed = event.button !== RIGHT_MOUSE_BUTTON
if (addPressed) return
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
event.stopPropagation()
@@ -1218,11 +1211,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!addPressed) return
addPressed = false
tabs.add?.()
}}
onMouseDragEnd={() => (addPressed = false)}
>
{" + "}
</text>
@@ -1,62 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { ThemeProvider } from "../../src/context/theme"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("releasing a transcript selection over tab controls does not activate them", async () => {
const [active, setActive] = createSignal("first")
const [added, setAdded] = createSignal(0)
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
select: setActive,
close() {},
move() {},
add: () => setAdded((value) => value + 1),
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
<text>selectable transcript text</text>
</box>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 3 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.pressDown(5, 1)
await app.mockMouse.release(40, 0)
expect(active()).toBe("first")
await app.mockMouse.click(40, 0)
expect(active()).toBe("second")
await app.mockMouse.pressDown(5, 1)
await app.mockMouse.release(58, 0)
expect(added()).toBe(0)
await app.mockMouse.click(58, 0)
expect(added()).toBe(1)
} finally {
app.renderer.destroy()
}
})
+641
View File
@@ -0,0 +1,641 @@
# TUI Package Extraction
## Goal
Move the canonical OpenCode terminal application from
`packages/opencode/src/cli/cmd/tui` into a self-contained workspace package while
the legacy CLI and the new CLI continue to use the same implementation.
Target package:
```text
packages/tui
name: @opencode-ai/tui
```
Target dependency graph:
```text
packages/opencode ---\
> @opencode-ai/tui -> @opencode-ai/sdk
packages/cli --------/
```
The TUI may directly depend on terminal and UI infrastructure such as
`@opentui/core`, `@opentui/solid`, `@opentui/keymap`, `solid-js`, Effect, and
generic presentation libraries. It must not depend on `packages/opencode`,
`packages/cli`, or `@opencode-ai/core`.
The SDK is the TUI's OpenCode boundary. Missing backend data or operations must
be added to the server API and generated SDK rather than imported from backend
implementation modules.
## Migration Rules
- Keep one canonical implementation of every TUI feature. Do not copy the full
TUI into `packages/cli` and synchronize two trees.
- Land each section below independently and commit it before starting the next
section.
- Keep each intermediate commit buildable and type-safe.
- Continue integrating team changes into whichever location is canonical for a
file at that point in the migration.
- Use temporary compatibility re-exports only when they materially reduce the
size or conflict risk of a section. Mark them for removal in a later section.
- Do not preserve private imports by creating aliases from `packages/tui` back
into `packages/opencode`.
- Do not replace private `packages/opencode` imports with `@opencode-ai/core`
imports merely to make the package compile.
- Keep tool rendering tolerant of unknown tools and wire-format changes. Local
checks over `unknown` input and metadata are acceptable; importing backend
tool implementations for type safety is not.
- Keep legacy CLI command parsing, server startup, worker management,
authentication, and config discovery outside `@opencode-ai/tui`.
## Ownership Boundary
### `@opencode-ai/tui` Owns
- OpenTUI renderer lifecycle shared by both CLI hosts
- Solid application composition
- Components, routes, dialogs, themes, keymaps, and UI primitives
- SDK client synchronization and event consumption
- Tool-call and tool-result presentation
- TUI-facing plugin contracts and presentation slots
- Resolved TUI configuration types, defaults, and pure validation
- Terminal behavior such as selection, clipboard integration, and local editor
launching when it is not host-specific
- TUI-local persistence such as prompt history, stash, frecency, selected model,
and selected theme
- Presentation utilities such as locale formatting, error display, record
checks, duration formatting, and layout helpers
### CLI Hosts Own
- Command definitions and argument parsing
- Starting, locating, and stopping servers and workers
- Authentication and transport construction
- Process-level signal policy
- Config file discovery, precedence, migration, and environment substitution
- Plugin package discovery, installation, and backend activation
- Upgrade checks and installation metadata
- Executable build wiring and worker path defines
### Server And SDK Own
- OpenCode domain data displayed by the TUI
- Session, message, workspace, file, provider, model, agent, and permission
operations
- Retry, revert, fork, share, and other backend actions
- Stable wire shapes for tool parts and plugin metadata
- Server capabilities needed to conditionally expose UI behavior
## Current Boundary
The canonical implementation currently lives under:
```text
packages/opencode/src/cli/cmd/tui
```
Its private dependency on `packages/opencode` is primarily expressed through
the `@/*` TypeScript alias, which resolves to `packages/opencode/src/*`.
`@tui/*` imports are internal to the TUI and are not themselves a package
boundary problem.
The main private dependency groups are:
- `@/util/*`: presentation helpers plus filesystem/process/RPC helpers
- `@/tool/*`: backend tool implementations used by renderers
- `@/session/*`, `@/provider/*`, and `@/reference/*`: backend data and actions
- `@/config/*`: config discovery, parsing, variables, and plugin resolution
- `@/plugin/*`: plugin loading and installation
- `@/cli/*`: yargs adapters, network setup, errors, and CLI presentation
- `@/server/*`: authentication and embedded server behavior
- `Global.Path`, `Flag`, and process environment reads
The initial extraction should reduce these dependencies in place before moving
the application root.
## Section 1: Create The Package Skeleton
Status: Completed. The private `@opencode-ai/tui` workspace package now has an
independent OpenTUI Solid JSX configuration, narrow root export, package-local
alias, and in-memory render smoke test. Neither CLI consumes the package yet.
Create `packages/tui` without moving the application root yet.
Tasks:
- Add `packages/tui/package.json` with the name `@opencode-ai/tui`.
- Add a package `tsconfig.json` configured for OpenTUI Solid JSX.
- Add `bunfig.toml` with the OpenTUI Solid preload for package-local development
and tests.
- Add package scripts for `typecheck` and package-local tests.
- Add direct dependencies used by the TUI. Do not rely on workspace hoisting.
- Add a narrow package export, initially only the package root and any explicit
testing entrypoint needed by migrated tests.
- Establish a package-local import convention. A local alias such as `@tui/*`
is acceptable, but it must resolve entirely inside `packages/tui`.
- Add a minimal package entrypoint and smoke test proving OpenTUI Solid TSX can
typecheck and render.
- Do not make either CLI consume the package yet.
Exit criteria:
- `packages/tui` typechecks independently.
- Its test command runs from `packages/tui`.
- The package has no dependency on `opencode`, `@opencode-ai/cli`, or
`@opencode-ai/core`.
Checkpoint commit:
```text
feat(tui): add standalone package skeleton
```
## Section 2: Move Presentation Utilities And Leaf UI
Status: Completed. Presentation utilities, bundled themes and their pure theme
engine, keybinding/keymap mechanics, and low-coupling border, link, and spinner
primitives now live in `@opencode-ai/tui`. The legacy host consumes explicit
package exports and retains only integration wrappers or compatibility
re-exports where backend and process concerns have not moved yet.
Move low-coupling code first so subsequent team changes land in the new package
without waiting for the application root migration.
Tasks:
- Move TUI presentation utilities into `packages/tui/src/util`, including the
portions of locale, error display, record checks, duration formatting, and
small functional helpers used by TUI code.
- Move pure TUI utilities already under the old TUI directory.
- Move themes and bundled theme JSON files.
- Move UI primitives and leaf components that have no private backend imports.
- Move pure keybinding schemas and keymap helpers that do not read host flags.
- Move related unit and snapshot tests.
- Update remaining old-tree consumers to import the new canonical modules.
- Use temporary compatibility re-exports from old TUI paths only if needed to
avoid a large unrelated import rewrite.
- Do not move `Filesystem`, `Process`, `Rpc`, worker startup, or config discovery
as generic utilities in this section.
Exit criteria:
- Moved files have no `@/...` imports.
- Tests for moved code run from `packages/tui`.
- Existing legacy TUI behavior and typecheck remain unchanged.
Checkpoint commit:
```text
refactor(tui): move presentation utilities and primitives
```
## Section 3: Remove Backend Tool Implementation Imports
Status: Completed. Legacy and V2 tool renderers now dispatch on SDK wire names,
accept `Record<string, unknown>` input and metadata, and use local guards for
nested presentation data. Web-search labels and structured metadata extraction
are TUI-owned, unknown tools retain the generic fallback, and no TUI source
imports backend tool implementations. The route components remain in the legacy
tree until the SDK state and route move in Section 6.
Make tool rendering depend only on SDK wire data and local presentation logic.
Tasks:
- Remove imports from `@/tool/*` in TUI routes and feature plugins.
- Key built-in renderers by SDK tool name strings such as `read`, `write`,
`edit`, `apply_patch`, `grep`, `glob`, `bash`, `question`, and `task`.
- Treat tool input, output metadata, and plugin-defined fields as `unknown` at
the package boundary.
- Add small local type guards only where a renderer needs a particular field.
- Preserve a generic fallback renderer for unknown and plugin-provided tools.
- Keep renderer failures local: malformed metadata must not crash the entire
session view.
- Replace backend-derived labels or IDs with TUI-owned presentation constants or
SDK-provided values.
- Move the affected tool presentation components and tests to `packages/tui`.
Exit criteria:
- No TUI source imports `@/tool/*`.
- Unknown tools render through the generic fallback.
- Existing built-in tool snapshots remain equivalent unless intentionally
updated and reviewed.
Checkpoint commit:
```text
refactor(tui): decouple tool rendering from backend tools
```
## Section 4: Make Runtime Inputs Explicit
Status: Completed for the shared runtime contract and legacy host. The TUI now
receives immutable launch-directory, path, capability, terminal/editor, startup,
and build inputs through `@opencode-ai/tui/runtime`. Movable app, component,
route, and feature-plugin code no longer reads OpenCode globals or process state;
command, config, plugin-loading, custom-theme discovery, editor/clipboard, and
Windows lifecycle adapters remain host-owned. `packages/cli` does not consume
this contract yet; that integration remains deferred to Section 9.
Replace process-global OpenCode state with resolved TUI inputs.
Define narrow inputs rather than one unstructured host object. Expected groups
include:
```ts
type TuiCapabilities = {
mouse: boolean
copyOnSelect: boolean
terminalTitle: boolean
workspaces: boolean
showTimeToFirstDraw: boolean
}
type TuiPaths = {
home: string
state: string
config: string
data: string
}
type TuiBuildInfo = {
version: string
channel?: string
}
```
Tasks:
- Inventory direct reads of `Flag`, `Global.Path`, and relevant environment
variables in movable TUI code.
- Pass resolved capabilities into the application/provider tree.
- Pass local path roots or a narrow TUI storage capability into persistence
contexts.
- Pass build/version information explicitly.
- Keep environment reads needed by legacy command or worker startup in
`packages/opencode` adapters.
- Give `packages/tui` sensible host-neutral defaults only when behavior is truly
local to a terminal client.
- Move contexts and components after their global dependencies are removed.
Exit criteria:
- Movable TUI code does not import `Flag` or `Global`.
- TUI tests can supply deterministic capabilities and storage paths.
- The legacy host constructs the required input through the public package API;
the new CLI integration remains deferred to Section 9.
Checkpoint commit:
```text
refactor(tui): make runtime capabilities explicit
```
## Section 5: Separate Resolved TUI Config From Host Config Loading
Status: Completed for the package config contract and legacy host adapter.
`@opencode-ai/tui/config` now owns schemas, defaults, keybind resolution, the
resolved config type, and the Solid config provider. The legacy host retains
file discovery, precedence, JSONC parsing, substitutions, migration,
source-relative sound paths, plugin origins, dependency installation, and
Effect services. `packages/cli` remains untouched until Section 9.
Move config semantics needed by rendering while retaining filesystem discovery
and migration in the legacy host.
Tasks:
- Move TUI config schemas, keybind schemas, defaults, and pure resolution to
`packages/tui`.
- Define the resolved config accepted by the public TUI entrypoint.
- Keep config path discovery, project/global precedence, migration, variable
expansion, and plugin package installation in `packages/opencode` initially.
- Make the legacy host produce the same resolved config shape.
- Add a new CLI adapter that can initially provide defaults or its own resolved
configuration.
- Update schema-generation imports to use the package's explicit config export
if schema generation still needs TUI schemas.
- Move pure config tests; retain discovery and migration integration tests in
`packages/opencode`.
Exit criteria:
- `packages/tui` does not import `@/config/*`.
- Config discovery can change without changing TUI rendering code.
- The old CLI still honors existing config precedence and migration behavior.
Checkpoint commit:
```text
refactor(tui): separate config resolution from loading
```
## Section 6: Move SDK State, Routes, And Backend Operations
Status: Completed for the SDK/domain boundary. SDK, project, event, legacy sync,
V2 sync, local model state, prompt persistence, and pure prompt helpers are now
canonical in `@opencode-ai/tui`. Configured references resolve through the new
generated `reference.list` SDK operation; prompt payloads rely on optional
server-assigned IDs; local attachment reads use the package platform contract.
Legacy route files remain in place until the plugin slot boundary and app-root
move, but their only private dependencies are plugin presentation or local host
adapters rather than OpenCode domain implementations.
Make the SDK the only OpenCode domain boundary used by the TUI.
Tasks:
- Move SDK client providers, event synchronization, routes, prompt UI, and
session views into `packages/tui`.
- Replace direct imports from `@/session/*`, `@/provider/*`, `@/reference/*`,
`@/lsp/*`, and other backend domains with SDK data or TUI-owned presentation
helpers.
- Replace direct backend actions such as retry with SDK calls.
- For each missing operation, add or adjust the server endpoint, regenerate the
JavaScript SDK with `./packages/sdk/js/script/build.ts`, and consume the
generated SDK API.
- Keep transport creation outside the package. Accept a base URL, headers,
custom fetch, event source, or constructed SDK client as appropriate.
- Keep local-only UI state in the TUI package rather than adding it to the
server API.
- Move affected tests and fixtures. Use real SDK/server integration where
practical instead of mocking backend modules.
Exit criteria:
- Domain-facing TUI code imports OpenCode data and operations only from
`@opencode-ai/sdk`.
- No TUI source imports private session, provider, reference, LSP, server, or
core domain implementations.
- SDK generation is clean after any API changes.
Checkpoint strategy:
This section may be split into multiple commits when an SDK gap is substantial.
Each commit must leave both the old TUI host and package tests working. Suggested
commit pattern:
```text
feat(sdk): expose <operation> for tui clients
refactor(tui): move <area> to sdk boundary
```
Final section checkpoint:
```text
refactor(tui): move sdk state and routes into package
```
## Section 7: Isolate Plugin Presentation From Plugin Loading
Status: Completed. Plugin slots, route registration, TUI-facing APIs, runtime
presentation state, and built-in feature plugins now live in
`@opencode-ai/tui`. The legacy host injects a narrow plugin host that retains
discovery, installation, manifest/config mutation, external module execution,
pure-mode filtering, and cleanup ownership. Missing or failing plugin hosts
degrade to the base TUI without blocking startup.
Keep plugin UI extensibility without importing the legacy plugin installer and
loader into the TUI package.
Tasks:
- Move plugin presentation slots, route contracts, and TUI-facing APIs into
`packages/tui` or the existing public plugin TUI contract package.
- Keep package discovery, installation, manifest resolution, backend activation,
and process lifecycle in the host.
- Define the serialized or runtime plugin presentation data the TUI requires.
- Prefer SDK-delivered plugin metadata when the behavior must also work for a
remote server.
- Make plugin absence or incompatibility degrade gracefully.
- Move plugin rendering tests to `packages/tui`; retain installation/loading
integration tests in `packages/opencode`.
Exit criteria:
- `packages/tui` does not import `@/plugin/*` or the old TUI plugin runtime.
- Remote and local TUI clients have a defined plugin behavior.
- Plugin UI failures cannot prevent the base TUI from starting.
Checkpoint commit:
```text
refactor(tui): separate plugin presentation from loading
```
## Section 8: Move The Application Root And Renderer Lifecycle
Status: Completed. `packages/tui` now owns the canonical application root,
provider composition, routes, components, parser presentation, renderer
configuration, and renderer lifecycle. Process mutation, Windows console
handling, backend worker startup, config loading, plugin loading, native audio,
and legacy platform implementations remain injected host adapters. Old source
paths are temporary compatibility re-exports for the legacy command host.
Move the canonical app composition after its dependencies have already crossed
the package boundary.
Tasks:
- Move `app.tsx`, remaining providers, routes, components, attention handling,
keymaps, and renderer lifecycle to `packages/tui`.
- Export a narrow public API such as:
```ts
export type TuiInput = {
url: string
directory?: string
headers?: RequestInit["headers"]
fetch?: typeof fetch
config: TuiConfig.Resolved
capabilities: TuiCapabilities
paths: TuiPaths
}
export function run(input: TuiInput): TuiHandle
export function createRenderer(config: TuiConfig.Resolved): Promise<CliRenderer>
```
- Preserve the existing lifecycle guarantees: readiness, waiting until exit,
idempotent cleanup, renderer destruction, SIGHUP handling where appropriate,
and terminal restoration.
- Keep Windows process adapters outside the package if they mutate host process
state; invoke them from CLI adapters around the package lifecycle.
- Keep OpenTUI parser-worker embedding in executable build scripts.
- Move app lifecycle and rendering tests to `packages/tui`.
Exit criteria:
- `packages/tui` contains the canonical application root.
- The package has no imports from `packages/opencode`, `packages/cli`, or
`@opencode-ai/core`.
- The package public API is sufficient for both old and new CLI adapters.
Checkpoint commit:
```text
refactor(tui): move application root into package
```
## Section 9: Convert Both CLIs To Thin Adapters
Status: Completed. The legacy thread and attach commands now lazily invoke the
public `@opencode-ai/tui` root while retaining worker/server/config/plugin and
process adapters. The new CLI default command launches the same package against
its authenticated daemon transport with a minimal local platform/host. Missing
legacy provider/config APIs currently degrade to the shared provider-connect
screen; source and compiled new-CLI behavior match, while named commands remain
outside the TUI path.
Make both executable packages consume the same TUI package.
Tasks:
- Keep the legacy yargs commands corresponding to current `thread.ts` and
`attach.ts` in `packages/opencode`.
- Keep the legacy embedded worker and server startup in `packages/opencode`.
- Change those adapters to load config, create transport inputs, and call the
public `@opencode-ai/tui` API.
- Change `packages/cli`'s default command handler to call the same public API.
- Remove the temporary `packages/cli/src/tui` shell after the shared package is
integrated.
- Remove duplicated OpenTUI lifecycle code from both hosts.
- Ensure non-TUI subcommands remain lazily isolated from OpenTUI startup.
- Update executable build scripts to bundle the shared package, parser worker,
assets, and any retained host worker.
Exit criteria:
- Both CLIs launch the same package implementation.
- There is no duplicate TUI source tree in `packages/cli`.
- Legacy attach and local-worker modes still work.
- Named non-TUI commands do not launch or eagerly initialize the TUI.
Checkpoint commit:
```text
refactor(cli): share tui package across command hosts
```
## Section 10: Remove Compatibility Paths And Finish Ownership
Status: Completed. Package source imports are self-contained, package exports
are narrowed to active host contracts, package-owned tests and snapshots live
under `packages/tui`, and the obsolete compatibility tree has been removed.
Legacy command, worker, config, plugin-loader, process, editor, audio, and event
adapters now live in explicit host-owned locations outside `src/cli/cmd/tui/`.
Delete migration scaffolding only after both hosts consume the package.
Tasks:
- Remove old TUI compatibility re-exports and the obsolete directory tree under
`packages/opencode/src/cli/cmd/tui`.
- Retain and relocate only true host adapters such as legacy commands, worker,
transport setup, and config loading.
- Remove obsolete `@tui/*` path mappings from `packages/opencode`.
- Remove stale test fixtures and update all imports to package exports.
- Narrow `@opencode-ai/tui` exports to intentional public entrypoints.
- Verify package manifests list every direct dependency and no accidental
dependency is supplied only by workspace hoisting.
- Update repository documentation describing TUI ownership and development.
Exit criteria:
- No production import references the old TUI source location.
- No source under `packages/tui` imports `@/...`, `@opencode-ai/core`, or either
executable package.
- The old TUI directory contains no canonical implementation files.
- The dependency graph has no cycle.
Checkpoint commit:
```text
refactor(tui): complete standalone package extraction
```
## Invariants To Preserve
- There is one canonical TUI implementation at every migration stage.
- Legacy TUI behavior remains available until its host is intentionally removed.
- The default new CLI command launches the TUI, while named subcommands continue
to route to their own handlers.
- Renderer cleanup restores the terminal on normal exit, interruption, startup
failure, and renderer destruction.
- TUI package imports do not reach into executable or backend implementation
packages.
- SDK wire data is treated as the source of truth for OpenCode domain state.
- Unknown tools and plugin data render safely without backend type imports.
- Remote-server use remains possible; the TUI must not require an in-process
backend implementation.
- TUI-local persistence remains local and does not become server state unless
there is an explicit product requirement.
- Team changes should be moved with their canonical file, not manually copied
between old and new implementations.
## Verification Gates
Run verification after every section, adding narrower tests for the area being
moved.
Package checks:
```text
cd packages/tui && bun typecheck
cd packages/tui && bun test
cd packages/opencode && bun typecheck
cd packages/cli && bun typecheck
```
Dependency checks:
```text
rg "from ['\"]@/" packages/tui/src
rg '@opencode-ai/core|packages/opencode|packages/cli' packages/tui
rg 'src/cli/cmd/tui|@tui/' packages/opencode/src packages/opencode/test
```
SDK checks when server APIs change:
```text
./packages/sdk/js/script/build.ts
git diff --check
```
Interactive smoke checks should run in `tmux` so the terminal can be captured
and cleaned up reliably:
- Start the legacy local TUI and confirm initial render.
- Start legacy attach mode against a server.
- Start the new CLI default command and confirm it renders the same package.
- Exit each mode with Ctrl-C and verify the process and terminal are restored.
- Run representative named commands in both CLIs and verify they do not launch
the TUI.
Compiled checks:
- Build the current-platform `packages/opencode` binary.
- Build the current-platform `packages/cli` binary.
- Run TUI and non-TUI smoke checks against both compiled binaries.
- Verify theme JSON, audio assets, OpenTUI parser worker, and retained backend
worker assets are included.
## Progress Tracking
- [x] Section 1: Create the package skeleton
- [x] Section 2: Move presentation utilities and leaf UI
- [x] Section 3: Remove backend tool implementation imports
- [x] Section 4: Make runtime inputs explicit
- [x] Section 5: Separate resolved TUI config from host config loading
- [x] Section 6: Move SDK state, routes, and backend operations
- [x] Section 7: Isolate plugin presentation from plugin loading
- [x] Section 8: Move the application root and renderer lifecycle
- [x] Section 9: Convert both CLIs to thin adapters
- [x] Section 10: Remove compatibility paths and finish ownership
Update each section's status and this checklist in the same commit that completes
the section.