mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 23:38:23 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b57c1cc47c | |||
| 082423126c | |||
| 7c4fbdd291 | |||
| a01cd34acc | |||
| d5a58e756f | |||
| e57a1c7930 | |||
| 98f5e86122 | |||
| c42c7f7793 | |||
| ab7a0bf65c | |||
| 552fd40ef8 | |||
| 8afcb3870e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Derive Promise plugin API request and response conversion from the canonical protocol schemas.
|
||||
@@ -2,7 +2,7 @@
|
||||
description: "Bump AI sdk dependencies minor / patch versions only"
|
||||
---
|
||||
|
||||
Please read @package.json and @packages/opencode/package.json.
|
||||
Please read @package.json and @packages/core/package.json.
|
||||
|
||||
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
|
||||
|
||||
|
||||
@@ -6,15 +6,7 @@ subtask: true
|
||||
|
||||
commit and push
|
||||
|
||||
make sure it includes a prefix like
|
||||
docs:
|
||||
tui:
|
||||
core:
|
||||
ci:
|
||||
ignore:
|
||||
wip:
|
||||
|
||||
For anything in the packages/web use the docs: prefix.
|
||||
Use `type(scope): summary` with one of these types: `feat`, `fix`, `docs`, `chore`, `refactor`, or `test`. The scope is optional.
|
||||
|
||||
prefer to explain WHY something was done from an end user perspective instead of
|
||||
WHAT was done.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description: Remove AI code slop
|
||||
---
|
||||
|
||||
Check the diff against dev, and remove all AI generated slop introduced in this branch.
|
||||
Check the diff against `origin/v2`, and remove all AI generated slop introduced in this branch.
|
||||
|
||||
This includes:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: effect
|
||||
description: Work with Effect v4 / effect-smol TypeScript code in this repo
|
||||
description: Work with Effect v4 TypeScript code in this repo
|
||||
---
|
||||
|
||||
# Effect
|
||||
@@ -9,10 +9,10 @@ This codebase uses Effect for typed, composable TypeScript services, schemas, an
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
|
||||
Use the current Effect v4 source, not memory or older Effect v2/v3 examples.
|
||||
|
||||
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
|
||||
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
|
||||
1. If `.opencode/references/effect` is missing, clone `https://github.com/Effect-TS/effect` there. Do this in the project, not in the skill folder.
|
||||
2. Search `.opencode/references/effect` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
|
||||
3. Also inspect existing repo code for local house style before introducing new patterns.
|
||||
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
|
||||
|
||||
@@ -27,12 +27,12 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
|
||||
- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
|
||||
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
|
||||
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
|
||||
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
|
||||
- Do not answer from memory. Verify against `.opencode/references/effect` or nearby code first.
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
|
||||
- Use `testEffect(...)` from `packages/core/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
|
||||
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
|
||||
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
|
||||
- Run tests from package directories such as `packages/core`; never run package tests from the repo root.
|
||||
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
|
||||
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
@@ -166,11 +166,11 @@ const table = sqliteTable("session", {
|
||||
|
||||
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
- Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
@@ -179,7 +179,7 @@ const table = sqliteTable("session", {
|
||||
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
|
||||
+63
-223
@@ -1,272 +1,112 @@
|
||||
# Contributing to OpenCode
|
||||
|
||||
We want to make it easy for you to contribute to OpenCode. Here are the most common type of changes that get merged:
|
||||
The changes most likely to be accepted are:
|
||||
|
||||
- Bug fixes
|
||||
- Additional LSPs / Formatters
|
||||
- Improvements to LLM performance
|
||||
- Support for new providers
|
||||
- Fixes for environment-specific quirks
|
||||
- Additional LSPs and formatters
|
||||
- LLM performance improvements
|
||||
- Environment-specific fixes
|
||||
- Missing standard behavior
|
||||
- Documentation improvements
|
||||
|
||||
However, any UI or core product feature must go through a design review with the core team before implementation.
|
||||
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).
|
||||
|
||||
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)
|
||||
Want to take on an issue? Leave a comment and a maintainer may assign it unless it is already being worked on.
|
||||
|
||||
> [!NOTE]
|
||||
> PRs that ignore these guardrails will likely be closed.
|
||||
|
||||
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.
|
||||
## Adding Providers
|
||||
|
||||
## Adding New Providers
|
||||
New providers should rarely require OpenCode changes. Add the provider to [models.dev](https://github.com/anomalyco/models.dev) first.
|
||||
|
||||
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
|
||||
## Development
|
||||
|
||||
## 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:
|
||||
OpenCode requires Bun 1.3 or newer. From the repository root:
|
||||
|
||||
```bash
|
||||
bun dev <directory>
|
||||
bun install
|
||||
bun dev [directory]
|
||||
```
|
||||
|
||||
To run OpenCode in the root of the opencode repo itself:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
bun dev .
|
||||
bun run dev:live [directory]
|
||||
```
|
||||
|
||||
### Building a "localcode"
|
||||
|
||||
To compile a standalone executable:
|
||||
For web development, run the backend and app in separate terminals. Other interfaces have root scripts:
|
||||
|
||||
```bash
|
||||
./packages/opencode/script/build.ts --single
|
||||
bun dev serve --port 4096
|
||||
bun run dev:web
|
||||
bun run dev:desktop
|
||||
bun run dev:www
|
||||
```
|
||||
|
||||
Then run it with:
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
./packages/opencode/dist/opencode-<platform>/bin/opencode
|
||||
cd packages/core
|
||||
bun run test
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||
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.
|
||||
|
||||
- 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`
|
||||
Follow the repository [style guide](./AGENTS.md).
|
||||
|
||||
### Understanding bun dev vs opencode
|
||||
## Pull Requests
|
||||
|
||||
During development, `bun dev` is the local equivalent of the built `opencode` command. Both run the same CLI interface:
|
||||
### Link Issues When Required
|
||||
|
||||
```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
|
||||
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.
|
||||
|
||||
# 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
|
||||
```
|
||||
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.
|
||||
|
||||
### Running the API Server
|
||||
Base branches on `v2`, not `dev`, and complete the provided pull request template.
|
||||
|
||||
To start the OpenCode headless API server:
|
||||
### Keep It Focused
|
||||
|
||||
```bash
|
||||
bun dev serve
|
||||
```
|
||||
- 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.
|
||||
|
||||
This starts the headless server on port 4096 by default. You can specify a different port:
|
||||
### Keep It Brief
|
||||
|
||||
```bash
|
||||
bun dev serve --port 8080
|
||||
```
|
||||
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.
|
||||
|
||||
### Running the Web App
|
||||
### Use Conventional Titles
|
||||
|
||||
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
|
||||
Use `type(scope): summary`. Supported types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. The scope is optional.
|
||||
|
||||
Examples:
|
||||
|
||||
- `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`
|
||||
- `docs: update contributing guide`
|
||||
- `fix(tui): restore scroll position`
|
||||
- `feat(app): add workspace search`
|
||||
|
||||
### Style Preferences
|
||||
## Issues
|
||||
|
||||
These are not strictly enforced, they are just general guidelines:
|
||||
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).
|
||||
|
||||
- **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.
|
||||
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.
|
||||
|
||||
@@ -568,6 +568,7 @@
|
||||
"@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,298 +0,0 @@
|
||||
# V1 to V2 Database Migration
|
||||
|
||||
## Approach
|
||||
|
||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||
- Remove migrations that exist only on the V2 branch.
|
||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||
- Keep the canonical migration focused on schema changes and dropping obsolete tables.
|
||||
- Run the V1 history backfill through an experimental server endpoint invoked by the CLI before it opens the TUI.
|
||||
- Show committed session progress while the endpoint runs.
|
||||
|
||||
Expose `GET /api/experimental/migration/v1` for status and a blocking `POST /api/experimental/migration/v1` to run or
|
||||
resume the backfill. The status is `required`, `running`, or `completed`. On startup, the CLI checks status first and
|
||||
renders no migration UI when it is already complete. For required or running status, it shows a spinner and waits for the
|
||||
blocking POST without a request timeout. While migration runs, poll GET once per second and render completed and total
|
||||
session counts. GET derives total from all session rows and completed from rows through the stored cursor; the count
|
||||
advances only after a session transaction commits. The POST returns `{ status: "completed" }`. Do not add a background
|
||||
job or streaming progress protocol. Interrupted calls resume from the stored cursor.
|
||||
Initially, only interactive TUI startup performs this check; noninteractive run, ACP, raw API, service, health, version,
|
||||
and help flows do not trigger the backfill.
|
||||
|
||||
Keep migration behavior in Core: status, semaphore, checkpointing, V1 decoding, transformation, and database writes.
|
||||
Protocol owns the experimental GET/POST contracts, Server handlers delegate to Core, and the interactive CLI owns only
|
||||
the status check and spinner presentation.
|
||||
|
||||
Guard the endpoint with one process-local Effect `Semaphore`. Concurrent callers wait; after the active call completes,
|
||||
waiting callers acquire the permit, observe the completion key, and return immediately. No distributed lock is required
|
||||
for the current single elected server process.
|
||||
|
||||
## Preserve
|
||||
|
||||
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
|
||||
|
||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||
workspace relationships.
|
||||
|
||||
Preserve existing non-null `session.agent` and `session.model` selections. Fill missing values from the latest ordinary
|
||||
V1 user message ordered by `time_created` and `id`, excluding compaction and subtask-only messages. Copy agent, provider
|
||||
ID, model ID, and variant, normalizing an absent variant to `default`.
|
||||
|
||||
Recompute session usage aggregates from all canonical V1 assistant messages, including compaction or other internal
|
||||
assistants omitted from the V2 projection. Overwrite session cost and input, output, reasoning, cache-read, and
|
||||
cache-write token totals with those sums.
|
||||
|
||||
Clear persisted `session.revert` state. A staged revert is transient operational state and may refer to omitted projection
|
||||
rows or unavailable snapshots; it must not resume automatically after upgrading. Preserve the underlying messages,
|
||||
parts, and file history.
|
||||
|
||||
Clear `session.time_compacting`, leave the new `time_suspended` column as `NULL`, and preserve session creation, update,
|
||||
and archive timestamps. Preserve project `time_initialized`; it is unrelated durable state.
|
||||
|
||||
Keep the legacy `todo` table and its data physically unchanged, but do not include it in the final V2 Drizzle schema.
|
||||
After generation, remove the generated `DROP TABLE todo` statement from the canonical migration so the table remains as
|
||||
unmanaged legacy storage.
|
||||
|
||||
## Per-Session Replacement
|
||||
|
||||
Do not truncate `event`, `event_sequence`, or `session_message` globally before the backfill. A whole-table delete can
|
||||
hold SQLite's writer lock long enough to block the running TUI.
|
||||
|
||||
Replace each legacy session's V2 state inside that session's checkpointed migration transaction. Delete `event` rows for
|
||||
the session aggregate, delete its `session_message` rows, rebuild its projection from canonical V1 `message` and `part`
|
||||
rows, and overwrite its `event_sequence` watermark. If migration of that session fails, all replacements roll back and
|
||||
the durable cursor remains at the previously committed session. Rows owned by sessions outside the legacy migration set
|
||||
remain untouched.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
|
||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||
V2 session APIs, which read `session_message`.
|
||||
|
||||
Do not fail the whole migration when a V1 message or part payload cannot be decoded. Skip an undecodable message's V2
|
||||
projection and log its session and message IDs. Skip an undecodable part while continuing to map its message, and perform
|
||||
special-message pairing only with decoded rows. Assign sequences after filtering. Leave every malformed source row
|
||||
untouched in the V1 tables.
|
||||
|
||||
Skip and log orphan parts whose source message does not exist and parts with unknown or unsupported types. Continue
|
||||
migrating the owning message and other valid parts. Include session, message, part ID, and observed type in warnings, and
|
||||
leave skipped source rows unchanged.
|
||||
|
||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||
avoid rewriting other persisted state that may refer to a message.
|
||||
|
||||
For ordinary user and assistant rows, preserve source `message.time_created` and `message.time_updated`. Entirely
|
||||
synthetic messages preserve their source timestamps, and synthetic rows split from mixed messages use the source user
|
||||
timestamps. A collapsed compaction uses the compaction user creation time and the later update time of the compaction
|
||||
user and summary assistant. Keep payload creation/completion times consistent with row timestamps.
|
||||
|
||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||
contiguous `session_message.seq` values starting at `0`.
|
||||
|
||||
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
|
||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||
payload.
|
||||
|
||||
Keep ordinary messages even when their transformed payload becomes empty after filtering. Preserve an empty V2 user row
|
||||
with `text: ""` and an empty V2 assistant row with `content: []` so IDs, chronology, and conversation structure remain
|
||||
stable. Omit only explicitly dropped internal concepts and undecodable messages.
|
||||
|
||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||
part mappings must be decided explicitly before implementing the backfill.
|
||||
|
||||
Do not carry the V1 subtask concept into the V2 projection. Omit user messages containing only `subtask` parts and omit
|
||||
the paired assistant task-tool messages generated from those markers. For mixed user messages, ignore the `subtask`
|
||||
parts while preserving ordinary content, and still omit assistant task-tool messages generated by the skipped subtasks.
|
||||
Keep all source rows unchanged in the V1 `message` and `part` tables.
|
||||
|
||||
Map ordinary V1 assistant `text` and `reasoning` parts into the V2 assistant `content` array in part order. Preserve text,
|
||||
including empty assistant text parts used as structural separators. Map V1 part metadata to optional V2 provider state.
|
||||
For reasoning, map `time.start` to `time.created` and optional `time.end` to `time.completed`.
|
||||
|
||||
Preserve V1 tool parts that are `pending` or `running`, but convert them to terminal V2 tool error states. Preserve the
|
||||
call ID, tool name, parsed input, metadata, and available start time. Use the assistant message creation time when the V1
|
||||
state has no start time. Set the error to type `tool.interrupted` with message
|
||||
`Tool execution was interrupted before V2 migration`. Never resume migrated tool executions.
|
||||
|
||||
For a completed V1 tool part, use `callID` as the V2 tool content ID and preserve the tool name and parsed input. Set the
|
||||
state to `completed`. Convert V1 output into the first text content item and convert stored output attachments into
|
||||
following file content items with their URI, MIME type, and filename. Preserve state metadata. Map `time.start` to
|
||||
`time.created` and `time.end` to `time.completed`. When `time.compacted` exists, use
|
||||
`[Old tool result content cleared]` as the only output and omit attachments.
|
||||
|
||||
For a failed V1 tool part, preserve the call ID, tool name, parsed input, metadata, and timestamps, and set the V2 state
|
||||
to `error`. Convert the V1 error string to a structured error with type `tool.execution`. If V1 metadata contains a string
|
||||
`output`, preserve it as optional V2 text content. Map `time.start` to `time.created` and `time.end` to `time.completed`.
|
||||
|
||||
For an ordinary V1 assistant message, preserve agent, provider ID, model ID, optional variant, creation and completion
|
||||
times, cost, and input/output/reasoning/cache token counts. Use `default` when the V1 variant is absent. Ignore V1
|
||||
`tokens.total` because it is derivable and V2 does not persist it.
|
||||
|
||||
Use V1 assistant `parentID` only while pairing compactions and skipped subtasks with their originating user messages. Do
|
||||
not persist it in ordinary V2 assistant rows; V2 uses ordered history rather than user/assistant parent links.
|
||||
|
||||
Ignore the optional V1 assistant `structured` output value. V2 has no equivalent top-level assistant field, and visible
|
||||
text and tool content are migrated separately. Retain the original structured value only in the V1 `message` row.
|
||||
|
||||
Ignore V1 assistant `mode` and historical `path` (`cwd` and `root`). Mode is redundant with the preserved assistant
|
||||
agent, and historical filesystem paths do not belong to the V2 assistant message contract. Retain them only in the V1
|
||||
`message` row.
|
||||
|
||||
For assistant finish reasons, preserve `stop`, `length`, `tool-calls`, `content-filter`, `error`, and `unknown`. Map every
|
||||
other nonempty V1 finish value to `unknown`, and leave the field absent when V1 omitted it. Do not retain unrecognized raw
|
||||
finish values in metadata.
|
||||
|
||||
Map V1 assistant errors into the current V2 `{ type, message }` storage shape. Normalize Auth, content-filter, context
|
||||
overflow, structured-output, output-length, aborted, API, and unknown errors to the established V2 string conventions,
|
||||
preserve the message, and discard V1-only retryability and raw provider details.
|
||||
|
||||
Ignore V1 `retry` parts. Do not populate the V2 assistant `retry` field during migration; historical retry state is not
|
||||
useful enough to preserve. The original retry rows remain in the V1 `part` table.
|
||||
|
||||
Do not emit V2 assistant content for V1 `step-start` and `step-finish` parts. Use the first available
|
||||
`step-start.snapshot` as `assistant.snapshot.start` and the last available `step-finish.snapshot` as
|
||||
`assistant.snapshot.end`. Continue to source finish, cost, and tokens from the assistant message itself. Ignore step
|
||||
markers without snapshots.
|
||||
|
||||
Do not emit assistant content for standalone V1 `snapshot` or `patch` parts. If no start snapshot came from `step-start`,
|
||||
use the first standalone snapshot value, then the first patch hash as a final fallback. Only `step-finish.snapshot` may
|
||||
populate the end snapshot. Merge patch file lists into `assistant.snapshot.files` in first-seen order with duplicates
|
||||
removed.
|
||||
|
||||
V2 follow-up: replace the open `SessionError.Error` string shape with a properly typed persisted error union. This is not
|
||||
a blocker for the V1 migration, which should target the current storage contract.
|
||||
|
||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||
|
||||
For an ordinary V2 user message, take visible V1 text parts that are neither ignored nor synthetic, preserve part order,
|
||||
and join their text with `"\n\n"`. Use an empty string when the message contains attachments but no ordinary text.
|
||||
|
||||
Ignore the optional V1 user-message `system` override. Do not create a V2 system message or preserve the override in
|
||||
metadata. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `tools` map. It represented request-time tool enablement for a historical step and
|
||||
must not affect future V2 execution. The original value remains in the V1 `message` row.
|
||||
|
||||
Ignore the optional V1 user-message `format` field and its schema. It controlled structured-output behavior for a
|
||||
historical request and must not affect future V2 runs. Preserve visible assistant text normally; retain the original
|
||||
format only in the V1 `message` row.
|
||||
|
||||
Ignore V1 user-message `summary` metadata, including title, body, and diffs. V2 user messages have no equivalent field,
|
||||
and session-level summary data is already persisted separately. Retain the original summary only in the V1 `message`
|
||||
row.
|
||||
|
||||
Map V1 `agent` parts into the V2 user message's `agents` array in part order. Preserve `name`. When the V1 part has
|
||||
`source`, map its `value`, `start`, and `end` into the V2 attachment's `mention.text`, `mention.start`, and `mention.end`.
|
||||
Omit `agents` when there are no agent parts.
|
||||
|
||||
Do not read the filesystem or network while migrating V1 file attachments. Attachment migration must be deterministic
|
||||
from database contents alone. Convert persisted `data:` URLs; represent non-embedded `file:`, HTTP, and other external
|
||||
URLs with deterministic text rather than fetching them. Keep the original V1 `part` rows unchanged.
|
||||
|
||||
For a V1 file backed by a `data:` URL, decode the URL and normalize its payload to base64 for the V2 attachment's `data`.
|
||||
Preserve `mime` and optional `filename` as `name`. Use a V2 `uri` source with the original URI for a V1 resource source;
|
||||
otherwise use an `inline` source. When V1 source text metadata exists, map its `value`, `start`, and `end` into the V2
|
||||
attachment mention. Leave `description` unset and preserve file-part order in the V2 `files` array.
|
||||
|
||||
For a non-embedded V1 file, do not create a V2 file attachment. Append
|
||||
`[Attachment unavailable after migration: <name-or-url> (<mime>)]` to the V2 user text in original part order, separated
|
||||
by blank lines. Prefer the V1 filename, then resource URI, then part URL for the label. The original URL remains only in
|
||||
the preserved V1 `part` row.
|
||||
|
||||
For a synthetic row split from a mixed user message, derive a generated-looking ID from the source message ID. Preserve
|
||||
the source ID's 12-character timestamp component and replace its 14-character random component with a deterministic
|
||||
base-62 encoding of a hash of `v1-synthetic:` plus the source message ID. If that candidate collides with an existing or
|
||||
derived message ID, deterministically retry with an incrementing salt. Place the synthetic row immediately after its
|
||||
source user row. Entirely synthetic messages continue to reuse their original message ID.
|
||||
|
||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||
admitted compaction input ID and preserves references to the initiating message.
|
||||
|
||||
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
|
||||
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
|
||||
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
|
||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||
assistant row.
|
||||
|
||||
Do not project incomplete or failed V1 compactions into `session_message`. Omit both the internal compaction user marker
|
||||
and its paired summary assistant when no successful summary was completed. Assign final sequence numbers after filtering
|
||||
so omitted compactions leave no gaps. Their source rows remain preserved in the V1 `message` and `part` tables.
|
||||
|
||||
After rebuilding a session's `session_message`, replace its `event_sequence` watermark with that session's maximum
|
||||
backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting before migrated
|
||||
history. The migrated session's prior `event` rows are removed in the same transaction.
|
||||
|
||||
## Drop
|
||||
|
||||
Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||
|
||||
- `session_input`
|
||||
- `session_context_epoch`
|
||||
- `data_migration`
|
||||
|
||||
Do not transfer `session_input` rows into `session_pending`.
|
||||
|
||||
## Create Empty
|
||||
|
||||
Let the generated migration create these tables empty:
|
||||
|
||||
- `instruction_blob`
|
||||
- `instruction_entry`
|
||||
- `instruction_state`
|
||||
- `session_pending`
|
||||
- `kv`
|
||||
|
||||
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
|
||||
|
||||
## Fork Storage
|
||||
|
||||
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
|
||||
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
|
||||
|
||||
- `before`: copy messages before the identified message.
|
||||
- `through`: copy messages through the identified message.
|
||||
|
||||
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
|
||||
schema.
|
||||
|
||||
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||
|
||||
## Execution
|
||||
|
||||
Before transforming V1 rows, look for `opencode-next.db` in the data directory. This file was used by pre-launch V2
|
||||
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
|
||||
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
|
||||
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
|
||||
|
||||
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||
|
||||
Store V1 backfill state in `kv`; do not retain a dedicated `data_migration` table. Store the last successfully migrated
|
||||
session ID under `migration.v1-v2.session.cursor` and write `migration.v1-v2.completed` with value `true` after every
|
||||
session finishes. Delete the cursor key on completion and return immediately on later calls when the completion key
|
||||
exists.
|
||||
|
||||
Absence of the completion key means migration is required, including on a fresh database. Running the endpoint against a
|
||||
database with no sessions completes immediately and writes the completion key; fresh database initialization does not
|
||||
seed migration state specially.
|
||||
|
||||
Process sessions in stable ID order. Rebuild one session in one transaction, including its `session_message` rows,
|
||||
session-level backfills, `event_sequence` watermark, and cursor update. If interrupted during a session, that transaction
|
||||
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
|
||||
cursor. Mark the migration complete after the final session and return immediately on later calls.
|
||||
|
||||
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
|
||||
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
|
||||
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
|
||||
successfully committed session advances the cursor.
|
||||
|
||||
## Testing
|
||||
|
||||
Detailed migration test design is deferred until after the canonical migration is implemented.
|
||||
@@ -23,14 +23,9 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
|
||||
|
||||
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
|
||||
|
||||
Primary in-repo integration point:
|
||||
Session integration lives in `packages/core/src/session`: `runner/llm.ts` owns orchestration, `model-request.ts` lowers Session state into `LLMRequest`, and `model-transport.ts` selects transport behavior.
|
||||
|
||||
- `packages/opencode/src/session/llm.ts` is the session-owned orchestration layer that decides whether a request uses AI SDK or this package's native route runtime.
|
||||
- `packages/opencode/src/session/llm/native-request.ts` is the lowering adapter from opencode's session/AI SDK-shaped data into this package's `LLMRequest` model.
|
||||
- `packages/opencode/src/session/llm/native-runtime.ts` is the execution adapter that calls raw `LLMClient.stream(request)` and bridges one provider turn of opencode tool calls through this package's typed dispatcher.
|
||||
- `packages/opencode/src/session/llm/ai-sdk.ts` keeps the default AI SDK path compatible by converting AI SDK stream parts into this package's shared `LLMEvent`s.
|
||||
|
||||
Keep this package independent of session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in `packages/opencode/src/session/llm.ts` and its local adapters.
|
||||
Keep this package independent of Session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in Core.
|
||||
|
||||
### Request Flow
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
- `opencode dev web` proxies `https://app.opencode.ai`, so local UI/CSS changes will not show there.
|
||||
- For local UI changes, run the backend and app dev servers separately.
|
||||
- Backend (from `packages/opencode`): `bun run --conditions=browser ./src/index.ts serve --port 4096`
|
||||
- Backend (from the repository root): `bun dev serve --port 4096`
|
||||
- App (from `packages/app`): `bun dev -- --port 4444`
|
||||
- Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`).
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
# V2 CLI and TUI development guide
|
||||
# CLI and TUI development guide
|
||||
|
||||
## Migration context
|
||||
|
||||
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Use `@opencode-ai/client` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
|
||||
- Preserve established TUI behavior unless the task intentionally changes it.
|
||||
- Load the `opencode-dev` skill before interactively running, debugging, or verifying opencode's V2 CLI, TUI, or server.
|
||||
|
||||
@@ -1,396 +1,3 @@
|
||||
export * as PluginPromise from "./promise.js"
|
||||
|
||||
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)),
|
||||
}),
|
||||
)
|
||||
export { fromPromise } from "@opencode-ai/plugin/promise/adapter"
|
||||
|
||||
@@ -789,10 +789,7 @@ const layer = Layer.effect(
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (recovered) {
|
||||
yield* execution.wakeActive(input.sessionID)
|
||||
return
|
||||
}
|
||||
if (recovered) return
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
@@ -878,7 +875,8 @@ const layer = Layer.effect(
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* execution.interrupt(sessionID, options)
|
||||
yield* execution.interrupt(sessionID)
|
||||
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as SessionExecution from "./execution.js"
|
||||
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Layer, Stream } 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"
|
||||
@@ -12,7 +11,6 @@ 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. */
|
||||
@@ -21,10 +19,8 @@ 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, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => 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>
|
||||
}
|
||||
@@ -49,7 +45,6 @@ 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) =>
|
||||
@@ -76,13 +71,12 @@ 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, promotable }),
|
||||
runner.drain({ sessionID, force, continuation }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -92,7 +86,7 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
if (result.type === "complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
return yield* drain(sessionID, false, result.continuation)
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
@@ -101,7 +95,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||
drain: (sessionID, force) => drain(sessionID, force),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
@@ -133,20 +127,16 @@ 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, options) =>
|
||||
coordinator.interrupt(
|
||||
sessionID,
|
||||
"user",
|
||||
options?.continue
|
||||
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
|
||||
: undefined,
|
||||
),
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -155,7 +145,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
@@ -165,7 +155,6 @@ 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,
|
||||
}),
|
||||
|
||||
@@ -349,14 +349,6 @@ 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.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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> {
|
||||
@@ -10,41 +9,26 @@ 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, 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>
|
||||
readonly wake: (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,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
) => Effect.Effect<void>
|
||||
readonly interrupt: (key: Key, reason?: Reason) => 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 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.
|
||||
* 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.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
request: Request
|
||||
pendingWake?: Request
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
interruptionReason?: Reason
|
||||
continuation?: {
|
||||
readonly request: Request
|
||||
readonly when: Effect.Effect<boolean>
|
||||
signaled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +43,7 @@ type Execution<E, Reason> = {
|
||||
* ```
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -71,26 +55,23 @@ 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, execution.request)).pipe(
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||
execution.request = execution.pendingWake
|
||||
execution.pendingWake = undefined
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
// 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, request: Request) => {
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
request,
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
@@ -106,7 +87,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) => finish(key, execution, exit)),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -116,22 +97,12 @@ 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>, continuation: boolean) => {
|
||||
if (continuation && execution.continuation) start(key, false, execution.continuation.request)
|
||||
else if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false)
|
||||
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)
|
||||
@@ -140,57 +111,26 @@ 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, "input").done)
|
||||
return Deferred.await(start(key, true).done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, request: Request = "input") =>
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
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
|
||||
execution.pendingWake = true
|
||||
return
|
||||
}
|
||||
start(key, false, request)
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const wakeActive = (key: Key) =>
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
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)
|
||||
}
|
||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = undefined
|
||||
execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
@@ -203,5 +143,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, wakeActive, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ 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"
|
||||
@@ -30,8 +29,6 @@ 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>
|
||||
}
|
||||
|
||||
|
||||
@@ -128,25 +128,22 @@ 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
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
||||
return { type: "complete" as const }
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||
if (yield* runPendingCompaction(input.sessionID)) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
const result = yield* runSteps(input.sessionID, continuation)
|
||||
if (result.type === "moved") return result
|
||||
if (promotable === "steer") return { type: "complete" as const }
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
@@ -159,14 +156,13 @@ 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 = initialPromotable
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingCompaction(sessionID)) 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
|
||||
@@ -519,14 +515,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.nextPromotable(db, sessionID, promotable)
|
||||
const selected =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
||||
if (selected?.type !== "compaction") return
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||
@@ -568,7 +564,9 @@ const layer = Layer.effect(
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const pending =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -14,7 +15,10 @@ 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"
|
||||
@@ -23,6 +27,53 @@ 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({
|
||||
@@ -44,6 +95,42 @@ 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 = {
|
||||
@@ -114,6 +201,7 @@ describe("fromPromise", () => {
|
||||
ctx.skill.list(),
|
||||
])
|
||||
seen.push(...results.map((result) => result.location.directory))
|
||||
expect((await ctx.integration.get({ integrationID: "missing" })).data).toBeNull()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ 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(
|
||||
@@ -42,16 +41,14 @@ const execution = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
}),
|
||||
interrupt: (sessionID, options) =>
|
||||
interrupt: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
interruptCalls.push(sessionID)
|
||||
interruptContinuations.push(options?.continue)
|
||||
}),
|
||||
wake: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
wakeActive: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
@@ -180,18 +177,31 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards interrupt continuation policy", () =>
|
||||
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", () =>
|
||||
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,35 +269,6 @@ 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* () {
|
||||
@@ -371,193 +342,6 @@ 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,8 +126,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -413,8 +413,7 @@ const execution = Layer.effect(
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -3089,24 +3088,6 @@ 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
|
||||
|
||||
@@ -115,7 +115,6 @@ 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),
|
||||
})
|
||||
|
||||
@@ -86,7 +86,6 @@ 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),
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@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:",
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
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)),
|
||||
}),
|
||||
)
|
||||
@@ -38,7 +38,7 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -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 pending steering input while queued work remains parked.",
|
||||
"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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- Current contracts are unversioned: use names like `Session`, `Permission`, `Question`, and identifiers like `Permission.Request`.
|
||||
- Legacy contracts retained for active compatibility, persistence, or migration are explicitly `V1`: use names like `SessionV1`, `PermissionV1`, and identifiers like `PermissionV1.Request`.
|
||||
- Do not preserve `V2` as the permanent name for the replacement architecture. Remove `V2` from current namespaces, brands, and identifiers as the contracts are normalized.
|
||||
- Retained V1 contracts should live under a dedicated `src/v1/` subtree once the V1 isolation PR runs. New/current code must not depend on that subtree.
|
||||
- Retained V1 contracts live under `src/v1/`. New/current code must not depend on that subtree.
|
||||
- V1 coexistence is temporary. Keep compatibility entrypoints only where migration requires them, and delete the V1 subtree when the legacy runtime is retired.
|
||||
- `@opencode-ai/protocol` and `@opencode-ai/sdk-next` are current `/api/...` surfaces.
|
||||
|
||||
|
||||
@@ -345,6 +345,7 @@ 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
|
||||
|
||||
@@ -760,7 +761,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
addPressed = event.button !== RIGHT_MOUSE_BUTTON
|
||||
if (addPressed) return
|
||||
if (!rail) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
@@ -769,8 +771,11 @@ 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}
|
||||
@@ -837,6 +842,7 @@ 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)
|
||||
@@ -1203,7 +1209,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
onMouseDown={(event) => {
|
||||
didDrag = false
|
||||
setDragging(undefined)
|
||||
if (event.button !== RIGHT_MOUSE_BUTTON) return
|
||||
addPressed = event.button !== RIGHT_MOUSE_BUTTON
|
||||
if (addPressed) return
|
||||
setContextMenu({ x: event.x, y: event.y })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -1211,8 +1218,11 @@ 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>
|
||||
|
||||
@@ -247,6 +247,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
||||
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
||||
onCleanup(
|
||||
event.on("session.moved", (evt) => {
|
||||
if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
|
||||
void Promise.allSettled([data.location.syncInfo(evt.data.location), data.location.vcs.sync(evt.data.location)])
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
event.on("session.inbox.enqueued", (evt) => {
|
||||
if (!enabled() || evt.data.item.type !== "user") return
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @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()
|
||||
}
|
||||
})
|
||||
@@ -197,6 +197,32 @@ test("loads VCS metadata for each persisted tab location", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("loads location metadata when an open session moves", async () => {
|
||||
const destination = `${directory}/moved-worktree`
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.locations.includes(directory) && setup.vcsLocations.includes(directory))
|
||||
setup.emit({
|
||||
id: "evt_moved",
|
||||
created: 1,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "first", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "first",
|
||||
location: { directory: destination },
|
||||
projectID: "project",
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => setup.data.session.get("first")?.location.directory === destination)
|
||||
await wait(() => setup.locations.includes(destination))
|
||||
await wait(() => setup.vcsLocations.includes(destination))
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("stores session tabs for the current working directory by default", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- Use parenthesized content folders for sidebar groups that must not add a URL segment. Keep ungrouped top-level pages directly under `content/docs/`.
|
||||
- Put static files in `public/` and reference them with root-relative paths.
|
||||
- The API reference is generated from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
|
||||
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
|
||||
- Keep documentation aligned with the current packages.
|
||||
|
||||
## Local development
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
## project
|
||||
|
||||
The goal is to let a single instance of OpenCode run sessions for multiple projects and different worktrees per project.
|
||||
|
||||
### api
|
||||
|
||||
```
|
||||
GET /project -> Project[]
|
||||
|
||||
POST /project/init -> Project
|
||||
|
||||
|
||||
GET /project/:projectID/session -> Session[]
|
||||
|
||||
GET /project/:projectID/session/:sessionID -> Session
|
||||
|
||||
POST /project/:projectID/session -> Session
|
||||
{
|
||||
id?: string
|
||||
parentID?: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
DELETE /project/:projectID/session/:sessionID
|
||||
|
||||
POST /project/:projectID/session/:sessionID/init
|
||||
|
||||
POST /project/:projectID/session/:sessionID/abort
|
||||
|
||||
POST /project/:projectID/session/:sessionID/share
|
||||
|
||||
DELETE /project/:projectID/session/:sessionID/share
|
||||
|
||||
POST /project/:projectID/session/:sessionID/compact
|
||||
|
||||
GET /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }[]
|
||||
|
||||
GET /project/:projectID/session/:sessionID/message/:messageID -> { info: Message, parts: Part[] }
|
||||
|
||||
POST /project/:projectID/session/:sessionID/message -> { info: Message, parts: Part[] }
|
||||
|
||||
POST /project/:projectID/session/:sessionID/revert -> Session
|
||||
|
||||
POST /project/:projectID/session/:sessionID/unrevert -> Session
|
||||
|
||||
POST /project/:projectID/session/:sessionID/permission/:permissionID -> Session
|
||||
|
||||
GET /project/:projectID/session/:sessionID/find/file -> string[]
|
||||
|
||||
GET /project/:projectID/session/:sessionID/file -> { type: "raw" | "patch", content: string }
|
||||
|
||||
GET /project/:projectID/session/:sessionID/file/status -> File[]
|
||||
|
||||
POST /log
|
||||
|
||||
// These are awkward
|
||||
|
||||
GET /provider?directory=<resolve path> -> Provider
|
||||
GET /config?directory=<resolve path> -> Config // think only tui uses this?
|
||||
|
||||
GET /project/:projectID/agent?directory=<resolve path> -> Agent
|
||||
GET /project/:projectID/find/file?directory=<resolve path> -> File
|
||||
|
||||
```
|
||||
@@ -1,145 +0,0 @@
|
||||
# Effect Drizzle SQLite Package
|
||||
|
||||
## Goal
|
||||
|
||||
Create a small workspace package that vendors the Drizzle `effect-sqlite` adapter shape for our repo. This is not an opencode storage abstraction. It is a local package that ports the Drizzle Effect SQLite implementation so we can use it before/independently of upstream release timing.
|
||||
|
||||
`packages/opencode` will use it internally, but the package itself should be generic: Drizzle + Effect + SQLite. No opencode paths, migrations, tables, transaction hooks, post-commit behavior, or domain language should live in this package.
|
||||
|
||||
## Package Shape
|
||||
|
||||
Add a package similar in style to `packages/http-recorder`:
|
||||
|
||||
- `packages/effect-drizzle-sqlite/package.json`
|
||||
- `packages/effect-drizzle-sqlite/src/index.ts`
|
||||
- `packages/effect-drizzle-sqlite/src/effect-sqlite/*`
|
||||
- `packages/effect-drizzle-sqlite/src/sqlite-core/effect/*`
|
||||
- `packages/effect-drizzle-sqlite/test/sqlite.test.ts`
|
||||
|
||||
Package name:
|
||||
|
||||
- `@opencode-ai/effect-drizzle-sqlite`
|
||||
|
||||
Initial exports:
|
||||
|
||||
```ts
|
||||
export { EffectLogger } from "drizzle-orm/effect-core"
|
||||
export * from "./effect-sqlite/driver"
|
||||
export * from "./effect-sqlite/session"
|
||||
export { migrate } from "./effect-sqlite/migrator"
|
||||
export * as EffectDrizzleSqlite from "."
|
||||
```
|
||||
|
||||
The package should follow Drizzle's adapter naming and semantics as closely as possible. Think of it as a vendored `drizzle-orm/effect-sqlite` package surface, not as a new storage service API.
|
||||
|
||||
## Upstream References
|
||||
|
||||
Use these as implementation references instead of inventing a custom API:
|
||||
|
||||
- Drizzle Effect Postgres current RC:
|
||||
- `/Users/kit/code/open-source/drizzle-orm-rc4-pr/drizzle-orm/src/effect-core/query-effect.ts`
|
||||
- `/Users/kit/code/open-source/drizzle-orm-rc4-pr/integration-tests/tests/pg/effect-sql.test.ts`
|
||||
- SQLite Effect branch/reference:
|
||||
- `/Users/kit/code/open-source/drizzle-orm-beta16/drizzle-orm/src/up-migrations/effect-sqlite.ts`
|
||||
- `/Users/kit/code/open-source/drizzle-orm-beta16/integration-tests/tests/sqlite/effect-sql.test.ts`
|
||||
- `/Users/kit/code/open-source/drizzle-orm-beta16/drizzle-orm/type-tests/sqlite/effect.ts`
|
||||
- Effect SQLite client source of truth:
|
||||
- `/Users/kit/code/open-source/effect-smol/packages/sql/sqlite-bun/src/SqliteClient.ts`
|
||||
- `/Users/kit/code/open-source/effect-smol/packages/sql/sqlite-node/test/Client.test.ts`
|
||||
- `/Users/kit/code/open-source/effect-smol/packages/sql/sqlite-node/test/SqliteMigrator.test.ts`
|
||||
|
||||
Important API patterns from those references:
|
||||
|
||||
- Drizzle queries are Effect-yieldable: `yield* db.select().from(table)`.
|
||||
- Transactions are Effect values: `yield* db.transaction((tx) => Effect.gen(...), { behavior: "immediate" })`.
|
||||
- SQLite clients come from Effect layers such as `SqliteClient.layer({ filename })`.
|
||||
- Migrations can run through Effect SQL/SQLite migrator mechanisms or Drizzle's `effect-sqlite/migrator` when available.
|
||||
|
||||
## Public Surface
|
||||
|
||||
Do not invent an `Interface<TDatabase>` abstraction unless the Drizzle port already has one. The public surface should mirror Drizzle's Effect adapters:
|
||||
|
||||
```ts
|
||||
const db = yield * EffectDrizzleSqlite.make({ relations }).pipe(Effect.provide(EffectDrizzleSqlite.DefaultServices))
|
||||
|
||||
yield * db.select().from(users)
|
||||
yield *
|
||||
db.transaction(
|
||||
(tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.insert(users).values({ name: "Ada" })
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `make` / `makeWithDefaults` should match the Drizzle Effect SQLite branch as much as possible.
|
||||
- `DefaultServices` should provide Drizzle's default logger/cache services, same as Effect Postgres.
|
||||
- The package should depend on Effect SQL SQLite clients (`@effect/sql-sqlite-bun` and/or node) the same way the Drizzle branch does.
|
||||
- Opencode-specific path/channel selection stays in `packages/opencode`.
|
||||
|
||||
## Opencode Adoption Notes
|
||||
|
||||
These are not package requirements, but they matter for the later opencode adoption PR.
|
||||
|
||||
The current `packages/opencode/src/storage/db.ts` has two non-obvious semantics that the opencode wrapper must preserve when it consumes this adapter:
|
||||
|
||||
- Nested `Database.use` inside `Database.transaction` sees the current transaction, not the root client.
|
||||
- `Database.effect` queues post-commit side effects while inside a transaction, and runs immediately outside a transaction.
|
||||
|
||||
The opencode wrapper can implement that using Effect context instead of `LocalContext`:
|
||||
|
||||
- A private transaction context holding `{ tx, afterCommit }`.
|
||||
- `withDb`/`db` methods read the current transaction context if present, otherwise use the root db.
|
||||
- `transaction` installs a transaction context around the effect.
|
||||
- Nested transactions can either reuse the existing tx initially, matching current behavior, or later use explicit savepoints if needed.
|
||||
|
||||
Do not remove this behavior while moving opencode to Effect SQLite. `SyncEvent.run` depends on transaction composability and `behavior: "immediate"` for sequencing correctness.
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Add `@opencode-ai/effect-drizzle-sqlite` with a minimal in-memory/file SQLite test schema.
|
||||
2. Port the Drizzle Effect SQLite adapter from the SQLite branch into the package, preserving upstream names and API shape.
|
||||
3. Test adapter-level guarantees:
|
||||
- query builders are yieldable Effect values,
|
||||
- `transaction(..., { behavior: "immediate" })` commits successful writes,
|
||||
- failed transaction rolls back,
|
||||
- migrations run once and in order,
|
||||
- close finalizer closes the underlying SQLite database.
|
||||
4. Add `@opencode-ai/effect-drizzle-sqlite` as a dependency of `packages/opencode`.
|
||||
5. Port `packages/opencode/src/storage/db.ts` to be a thin compatibility wrapper over the adapter plus opencode-specific transaction/post-commit context.
|
||||
6. Keep existing call sites working first:
|
||||
- `Database.Client()`
|
||||
- `Database.use(...)`
|
||||
- `Database.transaction(...)`
|
||||
- `Database.effect(...)`
|
||||
7. After compatibility is stable, migrate call sites from callback-style `Database.use` to yielding Effect Drizzle queries directly.
|
||||
8. Only then build domain stores like session/message/project stores on top of opencode's storage wrapper.
|
||||
|
||||
## Why This Is Cleaner Than Starting With SessionStorage
|
||||
|
||||
`SessionStorage` is a useful domain seam, but it does not answer the core adapter problem: how to make Drizzle SQLite Effect-native in this repo.
|
||||
|
||||
An Effect Drizzle SQLite package lets us vendor the adapter once. Then opencode can build its own storage wrapper on top, and `SessionStorage`, `MessageStorage`, event store, and projector writes can all share the same transaction and migration model.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Which client should the first package target: `@effect/sql-sqlite-bun`, `@effect/sql-sqlite-node`, or both behind separate layers?
|
||||
- How much source should we copy from the Drizzle branch versus import from catalog `drizzle-orm` internals?
|
||||
- What is the update path once Drizzle upstream ships `effect-sqlite`?
|
||||
- Should `afterCommit` stay opencode-specific until event publishing moves? Default answer: yes.
|
||||
- Should the compatibility wrapper preserve synchronous return types temporarily, or should the migration intentionally force Effect call sites?
|
||||
- Do CLI/admin raw SQL and sqlite shell stay in `packages/opencode`, or does the storage package expose backend capabilities for them?
|
||||
|
||||
## Recommended First PR
|
||||
|
||||
Make the first PR package-only and intentionally boring:
|
||||
|
||||
- Add `packages/effect-drizzle-sqlite`.
|
||||
- Use a tiny test schema, not opencode domain tables.
|
||||
- Prove Effect Drizzle SQLite queries, transactions, and migrations.
|
||||
- Do not migrate `packages/opencode` yet except possibly adding the dependency if needed for typechecking.
|
||||
|
||||
That gives us a focused place to validate the Effect SQLite approach before disturbing opencode's current database runtime.
|
||||
@@ -1,234 +0,0 @@
|
||||
# Remove `packages/opencode/src/storage/db.ts`
|
||||
|
||||
## Goal
|
||||
|
||||
Remove all production usages of the legacy `packages/opencode/src/storage/db.ts` module.
|
||||
|
||||
This means eliminating imports from `@/storage/db` or `./storage/db`, including:
|
||||
|
||||
- `Database.use(...)`
|
||||
- `Database.transaction(...)`
|
||||
- `Database.effect(...)`
|
||||
- `Database.Client()`
|
||||
- `Database.getPath()`
|
||||
- `Database.TxOrDb` / `Database.Transaction`
|
||||
- drizzle helpers re-exported from `@/storage/db`, such as `eq`
|
||||
|
||||
This does not mean removing SQLite or Drizzle everywhere in one step. The smaller target is deleting the opencode legacy wrapper by moving call sites onto deeper modules or onto the core/effect database adapter directly.
|
||||
|
||||
## Current Inventory
|
||||
|
||||
Production imports from `packages/opencode/src/storage/db.ts` are concentrated in 21 source files:
|
||||
|
||||
- `packages/opencode/src/account/repo.ts`
|
||||
- `packages/opencode/src/cli/cmd/db.ts`
|
||||
- `packages/opencode/src/cli/cmd/import.ts`
|
||||
- `packages/opencode/src/cli/cmd/stats.ts`
|
||||
- `packages/opencode/src/control-plane/workspace.ts`
|
||||
- `packages/opencode/src/index.ts`
|
||||
- `packages/opencode/src/node.ts`
|
||||
- `packages/opencode/src/permission/index.ts`
|
||||
- `packages/opencode/src/project/project.ts`
|
||||
- `packages/opencode/src/server/projectors.ts`
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts`
|
||||
- `packages/opencode/src/server/shared/fence.ts`
|
||||
- `packages/opencode/src/session/message-v2.ts`
|
||||
- `packages/opencode/src/session/projectors.ts`
|
||||
- `packages/opencode/src/session/prompt.ts`
|
||||
- `packages/opencode/src/session/session.ts`
|
||||
- `packages/opencode/src/share/share-next.ts`
|
||||
- `packages/opencode/src/storage/db.ts`
|
||||
- `packages/opencode/src/sync/index.ts`
|
||||
- `packages/opencode/src/worktree/index.ts`
|
||||
|
||||
There are 63 direct API/type references in those files. The references fall into the groups below.
|
||||
|
||||
## Group 1: Database Runtime And Startup
|
||||
|
||||
Status: Completed. Startup, the public node export, and database CLI tooling no longer import the legacy opencode database wrapper; `packages/opencode/src/storage/db.ts` has been deleted.
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/storage/db.ts`
|
||||
- `packages/opencode/src/index.ts`
|
||||
- `packages/opencode/src/node.ts`
|
||||
- `packages/opencode/src/cli/cmd/db.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `storage/db.ts` opens the singleton database, applies pragmas, exposes callback-style access, holds ambient transaction context, and queues post-commit effects.
|
||||
- `index.ts` no longer performs the removed JSON-to-SQLite migration during startup.
|
||||
- `node.ts` publicly re-exports `Database` from the legacy module.
|
||||
- `cli/cmd/db.ts` uses `Database.getPath()` to print the path, open a readonly Bun SQLite handle, run `sqlite3`, and vacuum.
|
||||
|
||||
Why this group comes first:
|
||||
|
||||
- These call sites define the seam currently used by every other group.
|
||||
- Deleting `storage/db.ts` requires an explicit replacement for database path, client acquisition, migration startup, and close/finalization.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Move database path and client startup behind the core/effect database module rather than the opencode wrapper.
|
||||
- Replace `Database.Client()` with an Effect-provided database service or a narrow startup-only adapter.
|
||||
- Replace the public `node.ts` re-export with either no export or a stable non-legacy database capability.
|
||||
- Keep `cli/cmd/db.ts` as an admin/raw SQLite tool, but make it ask the replacement database path provider instead of importing `@/storage/db`.
|
||||
|
||||
## Group 2: Sync Event Transaction Boundary
|
||||
|
||||
Status: Completed. `SyncEvent` and the opencode projector boundary were removed; session/message event projection now lives in core EventV2/projector infrastructure.
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/sync/index.ts`
|
||||
- `packages/opencode/src/session/projectors.ts`
|
||||
- `packages/opencode/src/server/projectors.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `SyncEvent.run` uses `Database.transaction(..., { behavior: "immediate" })` to allocate event sequence numbers safely.
|
||||
- `SyncEvent.process` wraps projector execution, event sequence writes, event log writes, and post-commit publishing in `Database.transaction(...)`.
|
||||
- `Database.effect(...)` queues publish side effects until after the transaction commits.
|
||||
- Projector functions accept `Database.TxOrDb` so they can write through either a root client or the active transaction.
|
||||
|
||||
Why this group is critical:
|
||||
|
||||
- It depends on the most non-obvious legacy behavior: nested `Database.use` inside a transaction must see the active transaction, and `Database.effect` must not publish until commit.
|
||||
- It is the central seam for session, message, permission, workspace, and server projection writes.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Replace `Database.TxOrDb` with an explicit projector transaction type from the replacement database adapter.
|
||||
- Move transaction context and after-commit behavior into an Effect-native sync event implementation.
|
||||
- Preserve immediate transaction behavior for sequence allocation.
|
||||
- Convert projector registration to accept the new transaction interface before converting every projector body.
|
||||
|
||||
Suggested first step:
|
||||
|
||||
- Create a narrow internal module for sync projection execution, then migrate `SyncEvent.project(...)` and projector type signatures to that module. Keep the implementation backed by the new database adapter until all projector users are moved.
|
||||
|
||||
## Group 3: Domain Repositories Already Behind Services
|
||||
|
||||
Status: Completed. These services no longer import the legacy opencode database wrapper.
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/account/repo.ts`
|
||||
- `packages/opencode/src/project/project.ts`
|
||||
- `packages/opencode/src/control-plane/workspace.ts`
|
||||
- `packages/opencode/src/share/share-next.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- These modules already expose Effect services or Effect functions, but internally wrap `Database.use` with local `db(...)` helpers or `Effect.try`.
|
||||
- `account/repo.ts` uses both `Database.use` and `Database.transaction` through a repository interface.
|
||||
- `project/project.ts` has the largest mixed usage: Effect service methods use a local `db(...)` helper, while legacy top-level functions still call `Database.use` directly.
|
||||
- `control-plane/workspace.ts` and `share/share-next.ts` have local Effect wrappers around `Database.use`.
|
||||
|
||||
Why this group is tractable:
|
||||
|
||||
- The public interfaces are already deeper than the database calls.
|
||||
- Most callers should not need to know whether these modules use Drizzle, files, or core services internally.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Inject the replacement database service into each Effect layer and yield Effect Drizzle queries directly.
|
||||
- Replace local callback wrappers with direct Effect queries.
|
||||
- Move remaining synchronous top-level helpers either behind the existing service interface or onto core modules.
|
||||
|
||||
Suggested order:
|
||||
|
||||
- Start with `account/repo.ts`; it has a clear repository interface and few call sites.
|
||||
- Then migrate `share/share-next.ts` and `control-plane/workspace.ts` local wrappers.
|
||||
- Leave `project/project.ts` for last in this group because it mixes project resolution, VCS, global bus emission, migration, and legacy top-level helpers.
|
||||
|
||||
## Group 4: Session And Message Read Models
|
||||
|
||||
Status: Completed. Session/message reads and projector writes have moved off the legacy opencode database wrapper.
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/session/session.ts`
|
||||
- `packages/opencode/src/session/message-v2.ts`
|
||||
- `packages/opencode/src/session/prompt.ts`
|
||||
- `packages/opencode/src/session/projectors.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `session/session.ts` uses `Database.use` for session reads, list queries, children, part lookup, and global list helpers.
|
||||
- `session/message-v2.ts` uses `Database.use` to page messages, hydrate parts, fetch one message, and fetch parts.
|
||||
- `session/prompt.ts` imports `eq` from `@/storage/db` and reads current prompt-related session/message rows directly.
|
||||
- `session/projectors.ts` uses `TxOrDb` for session/message usage projection helpers.
|
||||
|
||||
Why this group should be split:
|
||||
|
||||
- Reads can move independently from projector writes.
|
||||
- Message hydration is used by model prompt construction and session APIs, so changing it without a stable read module would spread query details across callers.
|
||||
- Projector writes are tied to Group 2's transaction type.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Create or use a session/message read module with Effect-native methods for `get`, `list`, `page`, `parts`, and prompt assembly reads.
|
||||
- Convert `session/projectors.ts` only after Group 2 defines the replacement projector transaction type.
|
||||
|
||||
Suggested order:
|
||||
|
||||
- Migrate `session/message-v2.ts` reads first because the module already centralizes message pagination and hydration.
|
||||
- Migrate `session/session.ts` read helpers next.
|
||||
- Migrate `session/prompt.ts` after message/session reads exist, and import drizzle operators from `drizzle-orm` if any direct SQL remains temporarily.
|
||||
|
||||
## Group 5: Legacy CLI And One-Off Admin Reads
|
||||
|
||||
Status: Completed. Remaining one-off CLI/admin reads and writes now use core database services or domain services instead of the legacy opencode database wrapper.
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/cli/cmd/import.ts`
|
||||
- `packages/opencode/src/cli/cmd/stats.ts`
|
||||
- `packages/opencode/src/server/shared/fence.ts`
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts`
|
||||
- `packages/opencode/src/worktree/index.ts`
|
||||
- `packages/opencode/src/permission/index.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `cli/cmd/import.ts` writes imported sessions/messages/parts directly with `Database.use`.
|
||||
- `cli/cmd/stats.ts` reads all sessions directly.
|
||||
- `server/shared/fence.ts` queries sessions for fence context.
|
||||
- `handlers/sync.ts` reads event rows for HTTP sync endpoints.
|
||||
- `worktree/index.ts` looks up a project row for worktree behavior.
|
||||
- `permission/index.ts` reads permission rows directly.
|
||||
|
||||
Why this group is mostly cleanup:
|
||||
|
||||
- Most usages are small and can either call an existing domain service or be given a narrow query function.
|
||||
- They are not defining shared transaction semantics.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Replace direct database reads with existing services where possible.
|
||||
- For admin/import commands, prefer dedicated import/stat modules rather than direct database access from command handlers.
|
||||
- For HTTP sync reads, move the event log query behind the sync event module.
|
||||
- For permission and worktree reads, call the permission/project services if available; otherwise add narrow repository methods.
|
||||
|
||||
## Recommended Migration Sequence
|
||||
|
||||
All migration groups are complete or superseded. `packages/opencode/src/storage/db.ts` has been deleted.
|
||||
|
||||
## Superseded: Data Migrations
|
||||
|
||||
Status: Superseded. No opencode data-migration group remains.
|
||||
|
||||
The previous opencode `data-migration.ts` service only backfilled session usage from message rows. That work is now covered by core database migration `packages/core/src/database/migration/20260510033149_session_usage.ts`, so there is no separate opencode data-migration group.
|
||||
|
||||
## Invariants To Preserve
|
||||
|
||||
- Nested reads inside a transaction must use the active transaction, not the root client.
|
||||
- `SyncEvent.run` sequence allocation must keep immediate transaction behavior.
|
||||
- Post-commit publish effects must not run before the transaction commits.
|
||||
- Existing schema ownership remains in `packages/core/src/**/*.sql.ts`; do not move table definitions back into `packages/opencode`.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
- `rg "@/storage/db|./storage/db|Database\.(use|transaction|effect|Client|getPath)|\bTxOrDb\b|\bTransaction\b" packages/opencode/src`
|
||||
- `bun typecheck` from `packages/opencode`
|
||||
- Relevant package tests from `packages/opencode`, not the repo root
|
||||
@@ -1,641 +0,0 @@
|
||||
# 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.
|
||||
+2
-2
@@ -25,13 +25,13 @@ Generated clients follow the assembled public `HttpApi`. GitHub issues own activ
|
||||
| [Session](./session.md) | Explain prompt admission, execution, instructions, compaction, and recovery boundaries. |
|
||||
| [Tools](./tools.md) | Explain tool construction, registration, execution, and outcome laws. |
|
||||
|
||||
## Decisions And Proposals
|
||||
## Decision Records
|
||||
|
||||
| Document | Status | Job |
|
||||
| ----------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------- |
|
||||
| [Event stream](./event-stream-architecture.md) | Accepted and implemented | Record why public events use one encoded feed with independent queues. |
|
||||
| [Managed restart continuation](./session-restart-continuation.md) | Superseded decision record | Preserve the graceful-only design replaced by write-ahead execution claims. |
|
||||
| [Provider policy](./provider-policy.md) | Proposed and unimplemented | Explore provider authorization independently from provider configuration. |
|
||||
| [Provider policy](./provider-policy.md) | Accepted and implemented | Record provider authorization independently from provider configuration. |
|
||||
|
||||
## Historical Context
|
||||
|
||||
|
||||
Reference in New Issue
Block a user