Compare commits

..
Author SHA1 Message Date
Claude 7aa35180ab fix(tui): forward directory-scoped events when TUI is in workspace mode (#26671)
The TUI's useEvent filter returned early in the workspace branch when the
inbound event's workspace did not match the TUI's active workspace —
including the common case where event.workspace was undefined because the
session has no workspaceID. The early return meant directory-scoped
events from external POSTs (web UI, SDK, curl) targeting a session
running in the same directory were silently dropped, even though the
server published them correctly with the right directory.

Treat events without a workspace label as directory-scoped and fall
through to the directory comparator so the TUI re-renders them. Also
adds an explicit return after the global-event handler so a global event
isn't forwarded twice.

Test cases (a)-(c) in httpapi-promptasync-events.test.ts now all forward;
(d) keeps cross-workspace events dropped.

https://claude.ai/code/session_011VwDFG5W32dnu1qnT3SGr1
2026-05-10 10:58:29 +00:00
Claude 4fdeb5aac2 test(server): isolate #26671 to TUI client-side event filter
prompt_async forks publish GlobalBus events with the correct
directory/workspace (proving Effect.forkIn preserves InstanceRef per
#26586). The TUI's useEvent filter, however, returns early in the
workspace branch when event.workspace is undefined, never falling through
to directory matching. Two tests document the boundary: the live request
asserts the publishing path is correct, the synthetic filter test pins
the dropped-event shape so the fix can flip a single branch.

https://claude.ai/code/session_011VwDFG5W32dnu1qnT3SGr1
2026-05-10 10:34:37 +00:00
190 changed files with 3049 additions and 7080 deletions
-1
View File
@@ -9,7 +9,6 @@
### General Principles
- Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()`
+17 -21
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -120,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -147,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -171,7 +171,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -195,7 +195,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.46",
"version": "1.14.44",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -329,7 +329,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.46",
"version": "1.14.44",
"bin": {
"opencode": "./bin/opencode",
},
@@ -412,7 +412,6 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
@@ -496,7 +495,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -534,7 +533,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -549,7 +548,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -584,7 +583,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -633,7 +632,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -678,7 +677,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
},
"overrides": {
"@types/bun": "catalog:",
@@ -2037,8 +2035,6 @@
"@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="],
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=",
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=",
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=",
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU="
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
}
}
-3
View File
@@ -7,8 +7,6 @@
"packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts --demo",
"dev:run-demo": "bun run --cwd packages/opencode --conditions=browser src/index.ts run --interactive --demo",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -135,7 +133,6 @@
},
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.46",
"version": "1.14.44",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.46",
"version": "1.14.44",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.46",
"version": "1.14.44",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.46",
"version": "1.14.44",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.46",
"version": "1.14.44",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46",
"version": "1.14.44",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-11
View File
@@ -1,5 +1,4 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -11,13 +10,6 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels where new experiments default to ON (unstable / internal users).
// Stable channels (`prod`, `latest`) stay opt-in.
const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"])
function unstableDefault(key: string) {
return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel))
}
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -56,9 +48,6 @@ export const Flag = {
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
// Default-on for dev/beta/local; opt-in for stable. Set
// OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on.
OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"),
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.46",
"version": "1.14.44",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.46",
"version": "1.14.44",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.46"
version = "1.14.44"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.46",
"version": "1.14.44",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-212
View File
@@ -1,212 +0,0 @@
# @opencode-ai/http-recorder
Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests
exercise real request shapes against deterministic, version-controlled
cassettes — no manual mocks, no flakes from upstream drift.
## Install
Internal package; depended on as `@opencode-ai/http-recorder` from another
workspace package.
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
```
## Quickstart
Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`.
The first run records to `test/fixtures/recordings/<name>.json`; subsequent
runs replay from it.
```ts
import { Effect } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { HttpRecorder } from "@opencode-ai/http-recorder"
const program = Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1"))
return yield* response.json
})
// Replay (default). Fails if the cassette is missing.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one"))))
// Record. Hits the upstream and writes the cassette.
Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))))
```
Set the mode from the environment in your test setup:
```ts
HttpRecorder.cassetteLayer("users/get-one", {
mode: process.env.RECORD === "true" ? "record" : "replay",
})
```
## Modes
| Mode | Behavior |
| ------------- | -------------------------------------------------------------------- |
| `replay` | Default. Match the request to a recorded interaction; error if none. |
| `record` | Execute upstream, append the interaction, write the cassette. |
| `passthrough` | Bypass the recorder entirely — just call upstream. |
## Cassette format
A cassette is JSON at `test/fixtures/recordings/<name>.json`:
```json
{
"version": 1,
"metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." },
"interactions": [
{
"transport": "http",
"request": { "method": "GET", "url": "...", "headers": {...}, "body": "" },
"response": { "status": 200, "headers": {...}, "body": "..." }
}
]
}
```
Cassettes are normal source files — review them, diff them, commit them.
## Request matching
By default, requests match on canonicalized method, URL, headers, and JSON
body (object keys sorted). Two dispatch strategies are available:
- **`match`** (default) — find the first recorded interaction whose request
matches the incoming request. Same request twice returns the same response.
- **`sequential`** — return interactions in the order they were recorded,
validating each one matches as the cursor advances. Use for ordered flows
where the same URL is hit multiple times with meaningful state changes
(pagination, retries, polling).
```ts
HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" })
```
Supply your own matcher via `match: (incoming, recorded) => boolean` for
custom equivalence (e.g. ignoring a timestamp field in the body).
## Redaction & secret safety
Cassettes get checked in, so the recorder is aggressive about not letting
secrets escape. Redaction is configured by composing a `Redactor`:
```ts
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
HttpRecorder.cassetteLayer("anthropic/messages", {
mode: process.env.RECORD === "true" ? "record" : "replay",
redactor: Redactor.defaults({
requestHeaders: { allow: ["content-type", "anthropic-version"] },
url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") },
body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }),
}),
})
```
`Redactor.defaults({ … })` composes the four built-in redactors with your
overrides. For full control, build the stack yourself:
```ts
const redactor = Redactor.compose(
Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }),
Redactor.responseHeaders(),
Redactor.url({ query: ["session-id"] }),
Redactor.body((parsed) => /* … */),
)
```
What each layer does:
- **`requestHeaders` / `responseHeaders`** — strip headers to a small
allow-list (request default: `content-type`, `accept`, `openai-beta`;
response default: `content-type`). Sensitive headers within the
allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens,
…) are replaced with `[REDACTED]`.
- **`url`** — query parameters matching common secret names (`api_key`,
`token`, `signature`, AWS signing params, …) are replaced with
`[REDACTED]`. URL user/password are replaced. `transform` runs after
built-in redaction for path-level scrubbing.
- **`body`** — receives the parsed JSON request body and returns a redacted
version. No-op for non-JSON bodies.
After assembling the cassette, the recorder scans every string for known
secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys,
AWS access keys, GitHub tokens, PEM blocks) and for values matching any
environment variable named like a credential. If anything is found, the
cassette is **not written** and the request fails with `UnsafeCassetteError`
listing what was detected.
## WebSocket recording
WebSocket support records the open frame plus client/server message
streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions
can live in the same cassette.
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "ws/subscribe",
mode: process.env.RECORD === "true" ? "record" : "replay",
cassette,
live: liveExecutor,
})
// use executor.open(...)
})
```
## Inspecting cassettes programmatically
`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and
`scan` (re-running the secret detector over an existing cassette). Useful
for CI checks:
```ts
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Effect } from "effect"
const audit = Effect.gen(function* () {
const cassettes = yield* HttpRecorder.Cassette.Service
const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) =>
cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))),
)
return findings.filter((r) => r.findings.length > 0)
})
```
## Options reference
```ts
type RecordReplayOptions = {
mode?: "record" | "replay" | "passthrough" // default: "replay"
directory?: string // default: <cwd>/test/fixtures/recordings
metadata?: Record<string, unknown> // merged into cassette.metadata
redactor?: Redactor // default: Redactor.defaults()
dispatch?: "match" | "sequential" // default: "match"
match?: (incoming, recorded) => boolean // custom matcher
}
```
## Layout
| File | Purpose |
| -------------- | -------------------------------------------------------------------------------- |
| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. |
| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. |
| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. |
| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. |
| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. |
| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. |
| `schema.ts` | Effect Schema definitions for the cassette JSON format. |
| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. |
| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46",
"version": "1.14.44",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+13 -18
View File
@@ -1,6 +1,6 @@
import { Context, Effect, FileSystem, Layer, PlatformError } from "effect"
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
import * as path from "node:path"
import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction"
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
@@ -37,18 +37,10 @@ export const layer = (options: { readonly directory?: string } = {}) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = new Map<string, { interactions: Interaction[]; findings: SecretFinding[] }>()
const directoriesEnsured = new Set<string>()
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
const pathFor = (name: string) => cassettePath(name, directory)
const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) {
const dir = path.dirname(pathFor(name))
if (directoriesEnsured.has(dir)) return
yield* fileSystem.makeDirectory(dir, { recursive: true })
directoriesEnsured.add(dir)
})
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fileSystem
@@ -69,7 +61,7 @@ export const layer = (options: { readonly directory?: string } = {}) =>
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* ensureDirectory(name)
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
@@ -78,12 +70,11 @@ export const layer = (options: { readonly directory?: string } = {}) =>
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const entry = recorded.get(name) ?? { interactions: [], findings: [] }
entry.interactions.push(interaction)
entry.findings.push(...secretFindings(interaction))
recorded.set(name, entry)
const cassette = cassetteFor(name, entry.interactions, metadata)
const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})]
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
const findings = cassetteSecretFindings(cassette)
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
@@ -111,3 +102,7 @@ export const layer = (options: { readonly directory?: string } = {}) =>
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
)
export const defaultLayer = layer()
export * as Cassette from "./cassette"
+95
View File
@@ -0,0 +1,95 @@
import { Option } from "effect"
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
import { decodeJson } from "./matching"
import { REDACTED, redactUrl, secretFindings } from "./redaction"
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (
expected &&
received &&
typeof expected === "object" &&
typeof received === "object" &&
!Array.isArray(expected) &&
!Array.isArray(received)
) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) =>
valueDiffs(
(expected as Record<string, unknown>)[key],
(received as Record<string, unknown>)[key],
`${base}.${key}`,
limit,
),
)
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
const lines = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
const interactions = httpInteractions(cassette)
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
+124 -54
View File
@@ -1,21 +1,26 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { Effect, Layer, Option, Ref } from "effect"
import {
FetchHttpClient,
Headers,
HttpBody,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
UrlParams,
} from "effect/unstable/http"
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import {
httpInteractions,
type Cassette,
type CassetteMetadata,
type HttpInteraction,
type ResponseSnapshot,
} from "./schema"
import * as CassetteService from "./cassette"
import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching"
import { appendOrFail, makeReplayState } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { redactUrl } from "./redaction"
import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema"
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
export type RecordReplayMode = "record" | "replay" | "passthrough"
@@ -23,15 +28,35 @@ export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redactor?: Redactor
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly responseHeaders?: ReadonlyArray<string>
readonly redactBody?: (body: unknown) => unknown
readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher
}
const responseHeaders = (
response: HttpClientResponse.HttpClientResponse,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> | undefined,
) => {
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
return merged
}
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
const isBinaryContentType = (contentType: string | undefined) =>
contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token))
const isBinaryContentType = (contentType: string | undefined) => {
if (!contentType) return false
const lower = contentType.toLowerCase()
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
isBinaryContentType(contentType)
@@ -43,19 +68,34 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) =>
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }),
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
}),
})
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
}),
})
const unsafeCassette = (
request: HttpClientRequest.HttpClientRequest,
name: string,
findings: ReadonlyArray<SecretFinding>,
) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
}),
})
export const recordingLayer = (
@@ -67,21 +107,61 @@ export const recordingLayer = (
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.Service
const redactor = options.redactor ?? defaults()
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
const match = options.match ?? defaultMatcher
const mode = options.mode ?? "replay"
const sequential = options.dispatch === "sequential"
const replay = yield* makeReplayState(cassetteService, name, httpInteractions)
const replay = yield* Ref.make<Cassette | undefined>(undefined)
const cursor = yield* Ref.make(0)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
return redactor.request({
const raw = yield* Effect.promise(() => web.text())
const body = options.redactBody
? Option.match(decodeJson(raw), {
onNone: () => raw,
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
})
: raw
return {
method: web.method,
url: web.url,
headers: Object.fromEntries(web.headers.entries()),
body: yield* Effect.promise(() => web.text()),
})
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
Object.fromEntries(web.headers.entries()),
requestHeadersAllow,
options.redact?.headers,
),
body,
}
})
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
Effect.gen(function* () {
const interactions = httpInteractions(cassette)
if (sequential) {
const index = yield* Ref.get(cursor)
const interaction = interactions[index]
if (!interaction)
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request)) {
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
}
yield* Ref.update(cursor, (n) => n + 1)
return { interaction, detail: "" }
}
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
})
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
yield* Ref.set(replay, cassette)
return cassette
})
return HttpClient.make((request) => {
@@ -89,21 +169,18 @@ export const recordingLayer = (
if (mode === "record") {
return Effect.gen(function* () {
const incoming = yield* snapshotRequest(request)
const currentRequest = yield* snapshotRequest(request)
const response = yield* upstream.execute(request)
const captured = yield* captureResponseBody(response, response.headers["content-type"])
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
const captured = yield* captureResponseBody(response, headers["content-type"])
const interaction: HttpInteraction = {
transport: "http",
request: incoming,
response: redactor.response({
status: response.status,
headers: response.headers as Record<string, string>,
...captured,
}),
request: currentRequest,
response: { status: response.status, headers, ...captured },
}
yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe(
Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))),
)
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
const findings = result.findings
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
@@ -112,21 +189,14 @@ export const recordingLayer = (
}
return Effect.gen(function* () {
const cassette = yield* loadReplay(request)
const incoming = yield* snapshotRequest(request)
const interactions = yield* replay.load.pipe(
Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)),
)
const result = sequential
? selectSequential(interactions, incoming, match, yield* replay.cursor)
: selectMatch(interactions, incoming, match)
if (!result.interaction)
return yield* Effect.fail(
transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`),
)
if (sequential) yield* replay.advance
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
if (!interaction) return yield* fixtureMismatch(request, name, detail)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(result.interaction.response), result.interaction.response),
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
})
+7 -23
View File
@@ -1,26 +1,10 @@
export type {
CassetteMetadata,
HttpInteraction,
Interaction,
RequestSnapshot,
ResponseSnapshot,
WebSocketFrame,
WebSocketInteraction,
} from "./schema"
export { hasCassetteSync } from "./storage"
export { defaultMatcher, type RequestMatcher } from "./matching"
export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction"
export { UnsafeCassetteError } from "./recorder"
export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect"
export {
makeWebSocketExecutor,
type WebSocketConnection,
type WebSocketExecutor,
type WebSocketRecordReplayOptions,
type WebSocketRequest,
} from "./websocket"
export * from "./schema"
export * from "./redaction"
export * from "./matching"
export * from "./diff"
export * from "./storage"
export * from "./websocket"
export * from "./effect"
export * as Cassette from "./cassette"
export * as Redactor from "./redactor"
export * as HttpRecorder from "."
+1 -89
View File
@@ -1,6 +1,5 @@
import { Option, Schema } from "effect"
import { REDACTED, secretFindings } from "./redaction"
import type { HttpInteraction, RequestSnapshot } from "./schema"
import type { RequestSnapshot } from "./schema"
const JsonValue = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
@@ -35,90 +34,3 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (isRecord(expected) && isRecord(received)) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit))
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray<string> => {
const lines: string[] = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (interactions: ReadonlyArray<HttpInteraction>, incoming: RequestSnapshot): string => {
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const selectMatch = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) }
}
export const selectSequential = (
interactions: ReadonlyArray<HttpInteraction>,
incoming: RequestSnapshot,
match: RequestMatcher,
index: number,
): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => {
const interaction = interactions[index]
if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request))
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
return { interaction, detail: "" }
}
-61
View File
@@ -1,61 +0,0 @@
import { Effect, PlatformError, Ref, Scope } from "effect"
import type * as CassetteService from "./cassette"
import type { SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
export class UnsafeCassetteError extends Error {
readonly _tag = "UnsafeCassetteError"
constructor(
readonly cassetteName: string,
readonly findings: ReadonlyArray<SecretFinding>,
) {
super(
`Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings
.map((finding) => `${finding.path} (${finding.reason})`)
.join(", ")}`,
)
}
}
export const appendOrFail = (
cassette: CassetteService.Interface,
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
): Effect.Effect<Cassette, UnsafeCassetteError> =>
cassette.append(name, interaction, metadata).pipe(
Effect.orDie,
Effect.flatMap(({ cassette: result, findings }) =>
findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)),
),
)
export interface ReplayState<T> {
readonly load: Effect.Effect<ReadonlyArray<T>, PlatformError.PlatformError>
readonly cursor: Effect.Effect<number>
readonly advance: Effect.Effect<void>
}
export const makeReplayState = <T>(
cassette: CassetteService.Interface,
name: string,
project: (cassette: Cassette) => ReadonlyArray<T>,
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
Effect.gen(function* () {
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
const position = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const used = yield* Ref.get(position)
if (used === 0) return
const interactions = yield* load.pipe(Effect.orDie)
if (used < interactions.length)
yield* Effect.die(
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
)
}),
)
return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) }
})
-76
View File
@@ -1,76 +0,0 @@
import { Option } from "effect"
import { decodeJson } from "./matching"
import { redactHeaders, redactUrl } from "./redaction"
import type { RequestSnapshot, ResponseSnapshot } from "./schema"
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
const identity = <T>(value: T) => value
export interface Redactor {
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
}
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
return {
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
}
}
export interface HeaderOptions {
readonly allow?: ReadonlyArray<string>
readonly redact?: ReadonlyArray<string>
}
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
request: (snapshot) => ({
...snapshot,
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
}),
})
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
response: (snapshot) => ({
...snapshot,
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
}),
})
export interface UrlOptions {
readonly query?: ReadonlyArray<string>
readonly transform?: (url: string) => string
}
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
})
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
request: (snapshot) => ({
...snapshot,
body: Option.match(decodeJson(snapshot.body), {
onNone: () => snapshot.body,
onSome: (parsed) => JSON.stringify(transform(parsed)),
}),
}),
})
export interface DefaultRedactorOverrides {
readonly requestHeaders?: HeaderOptions
readonly responseHeaders?: HeaderOptions
readonly url?: UrlOptions
readonly body?: (parsed: unknown) => unknown
}
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
compose(
requestHeaders(overrides.requestHeaders),
responseHeaders(overrides.responseHeaders),
url(overrides.url),
...(overrides.body ? [body(overrides.body)] : []),
)
+7 -1
View File
@@ -7,13 +7,19 @@ export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtu
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
name,
recordedAt: new Date().toISOString(),
...(metadata ?? {}),
})
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) },
metadata: metadataFor(name, metadata),
interactions,
})
+96 -49
View File
@@ -2,9 +2,10 @@ import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { appendOrFail, makeReplayState } from "./recorder"
import { defaults, type Redactor } from "./redactor"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export interface WebSocketRequest {
readonly url: string
@@ -27,32 +28,63 @@ export interface WebSocketRecordReplayOptions<E> {
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
readonly redactor?: Redactor
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly compareClientMessagesAsJson?: boolean
}
const headersRecord = (headers: Headers.Headers): Record<string, string> =>
const headersRecord = (headers: Headers.Headers) =>
Object.fromEntries(
Object.entries(headers as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
Object.entries(headers as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.toSorted(([a], [b]) => a.localeCompare(b)),
)
const encodeFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? { kind: "text", body: message }
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const openSnapshot = (
request: WebSocketRequest,
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
) => ({
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
headersRecord(request.headers),
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
options.redact?.headers,
),
})
const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array =>
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
const frameText = (frame: WebSocketFrame) => {
if (frame.kind === "text") return frame.body
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
}
const frameMessage = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const decodeFrameText = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64"))
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? textFrame(message)
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
new Error(
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
)
const mismatch = (message: string, actual: unknown, expected: unknown) =>
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
throw mismatch(message, actual, expected)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
@@ -62,7 +94,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
})
const expectedText = decodeFrameText(expected)
const expectedText = frameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
}
@@ -72,16 +104,6 @@ export const makeWebSocketExecutor = <E>(
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const mode = options.mode ?? "replay"
const redactor = options.redactor ?? defaults()
const openSnapshot = (request: WebSocketRequest) => {
const redacted = redactor.request({
method: "GET",
url: request.url,
headers: headersRecord(request.headers),
body: "",
})
return { url: redacted.url, headers: redacted.headers }
}
if (mode === "passthrough") return options.live
@@ -96,21 +118,21 @@ export const makeWebSocketExecutor = <E>(
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
yield* appendOrFail(
options.cassette,
options.name,
{ transport: "websocket", open: openSnapshot(request), client, server },
options.metadata,
).pipe(Effect.orDie)
const result = yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request, options), client, server },
options.metadata,
)
.pipe(Effect.orDie)
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
})
return {
sendText: (message) =>
connection
.sendText(message)
.pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))),
sendText: (message: string) =>
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
messages: connection.messages.pipe(
Stream.map((message) => {
server.push(encodeFrame(message))
server.push(receivedFrame(message))
return message
}),
),
@@ -120,20 +142,44 @@ export const makeWebSocketExecutor = <E>(
}
}
const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions)
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
undefined,
)
const cursor = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const input = yield* Ref.get(replay)
if (!input) return
yield* assertEqual(
`Unused recorded WebSocket interactions in ${options.name}`,
yield* Ref.get(cursor),
input.interactions.length,
)
}),
)
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const input = {
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
}
yield* Ref.set(replay, input)
return input
})
return {
open: (request) =>
Effect.gen(function* () {
const interactions = yield* replay.load.pipe(Effect.orDie)
const index = yield* replay.cursor
const interaction = interactions[index]
open: (request) => {
return Effect.gen(function* () {
const input = yield* loadReplay()
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
const interaction = input.interactions[index]
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* replay.advance
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open)
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
const messageIndex = yield* Ref.make(0)
return {
sendText: (message) =>
sendText: (message: string) =>
Effect.gen(function* () {
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage(
@@ -143,7 +189,7 @@ export const makeWebSocketExecutor = <E>(
options.compareClientMessagesAsJson === true,
)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
close: Effect.gen(function* () {
yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`,
@@ -152,6 +198,7 @@ export const makeWebSocketExecutor = <E>(
)
}),
}
}),
})
},
}
})
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
@@ -6,8 +6,7 @@ import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/effect"
import { cassetteFor, formatCassette, parseCassette } from "../src/storage"
import { redactedErrorRequest } from "../src/diff"
const post = (url: string, body: object) =>
Effect.gen(function* () {
@@ -146,7 +145,7 @@ describe("http-recorder", () => {
})
test("formats websocket cassettes with shared metadata", () => {
const cassette = cassetteFor(
const cassette = HttpRecorder.cassetteFor(
"websocket/basic",
[
{
@@ -160,7 +159,7 @@ describe("http-recorder", () => {
)
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(parseCassette(formatCassette(cassette))).toEqual(cassette)
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
@@ -169,7 +168,7 @@ describe("http-recorder", () => {
const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
cassetteFor(
HttpRecorder.cassetteFor(
"websocket/replay",
[
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46",
"version": "1.14.44",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
@@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError } from "../../src"
@@ -31,7 +30,7 @@ const recorded = recordedTests({
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
options: { requestHeaders: ["content-type", "anthropic-version"] },
})
describe("Anthropic Messages sad-path recorded", () => {
@@ -1,4 +1,3 @@
import { Redactor } from "@opencode-ai/http-recorder"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import * as Gemini from "../../src/protocols/gemini"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -67,7 +66,7 @@ const redactCloudflareURL = (url: string) =>
.replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/")
const cloudflareOptions = {
redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }),
redact: { url: redactCloudflareURL },
}
describeRecordedGoldenScenarios([
@@ -103,7 +102,7 @@ describeRecordedGoldenScenarios([
prefix: "anthropic-messages",
model: anthropicHaiku,
requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
options: { requestHeaders: ["content-type", "anthropic-version"] },
scenarios: ["text", "tool-call"],
},
{
@@ -112,7 +111,7 @@ describeRecordedGoldenScenarios([
model: anthropicOpus,
requires: ["ANTHROPIC_API_KEY"],
tags: ["flagship"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
options: { requestHeaders: ["content-type", "anthropic-version"] },
scenarios: [{ id: "tool-loop", temperature: false }],
},
{
+1 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.46",
"version": "1.14.44",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -13,8 +13,6 @@
"build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"dev:demo": "bun run --conditions=browser ./src/index.ts --demo",
"dev:run-demo": "bun run --conditions=browser ./src/index.ts run --interactive --demo",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit"
},
@@ -121,7 +119,6 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@silvia-odwyer/photon-node": "0.3.4",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
@@ -1,204 +0,0 @@
# OpenAPI Translation Cleanup Plan
## Goal
Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface.
The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation.
## Current Culprit
`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility.
The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime.
## Non-Negotiables
- Do not break the generated JavaScript SDK without an explicit versioned migration plan.
- Runtime route schemas are the source of truth for accepted params, payloads, and responses.
- `/doc`, generated SDK types, and runtime validation must agree for every endpoint.
- Prefer endpoint or schema annotations over post-generation spec surgery.
- Remove one category of rewrite at a time, with focused compatibility checks.
## PR Checklist
Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started.
Current combined PR scope:
- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`.
- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes.
- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs.
### PR 1: Add OpenAPI/Runtime Query Drift Tests
- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`.
- `[x]` Import `OpenApi.fromApi` and `PublicApi`.
- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`.
- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes.
- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema.
- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params.
- `[x]` Keep this part test-only.
Verification:
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
- `[x]` `bun typecheck` from `packages/opencode`.
### PR 2: Delete Spec-Only Workspace Query Injection
- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
- `[x]` Delete `InstanceQueryParameters`.
- `[x]` Delete the `isInstanceRoute` constant.
- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation.
- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`.
- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes.
- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only.
Notes:
- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params.
- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing.
Expected code shape:
```ts
for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
```
Verification:
- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`.
- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `[x]` `./packages/sdk/js/script/build.ts` from repo root.
- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK.
- `[x]` `bun typecheck` from `packages/opencode`.
### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers
- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`.
- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time.
- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`.
- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`.
- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet.
Concrete first targets:
- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform.
- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter.
- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly.
Verification:
- Focused HTTP tests for changed query fields.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK request param types before deleting each override.
- `bun typecheck` from `packages/opencode`.
### PR 4: Move Path Parameter Patterns Into ID Schemas
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
- Delete one path override only after generated OpenAPI is unchanged for that param.
Concrete first targets:
- `[x]` `sessionID`
- `[x]` `messageID`
- `[x]` `partID`
- `[x]` `permissionID`
- `[x]` `ptyID`
- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern.
Verification:
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated path param types and patterns.
- `bun typecheck` from `packages/opencode`.
### PR 5: Replace Built-In Error Rewrites With Declared API Errors
- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`.
- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there.
- Update handlers to fail with the declared API errors at the boundary.
- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible.
- Do this group by group, starting with one small route group.
Concrete first targets:
- `groups/config.ts` `PATCH /config` bad request.
- `groups/session.ts` endpoints that already translate domain not-found errors.
- `groups/file.ts` if any handler currently relies on built-in error shape.
Verification:
- Focused HTTP tests asserting response body shape for changed error paths.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect SDK error union diff.
- `bun typecheck` from `packages/opencode`.
### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It
- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`.
- Decide whether SDK should expose auth in generated operation metadata.
- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code.
- If removing it, update SDK generation expectations and docs in the same PR.
Verification:
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated client call signatures and error unions.
- Do not merge if auth churn changes normal SDK call ergonomics unintentionally.
### PR 7: Tackle Component Shape Rewrites One At A Time
- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`.
- For each rewrite, make a tiny PR that removes or narrows only that rewrite.
- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first.
Concrete first targets:
- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially.
- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source.
- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields.
Verification:
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK type-name and optionality diffs.
## Upstream Middleware Query Support
Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation.
Target in `effect-smol`:
- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation.
- Make runtime request decoding include middleware query schemas.
- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware.
Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`.
## Suggested PR Order
1. Add drift detection tests only.
2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas.
3. Convert query type overrides into route/schema-level helpers where possible.
4. Convert path parameter overrides into schema annotations or upstream fixes.
5. Replace built-in error response rewrites with explicit declared API errors by route group.
6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable.
## Verification Checklist Per PR
- Focused HTTP tests for changed routes.
- OpenAPI drift tests.
- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`.
- `./packages/sdk/js/script/build.ts` from repo root.
- Inspect generated SDK diff for public API churn.
- `bun typecheck` from `packages/opencode`.
+44 -43
View File
@@ -26,7 +26,9 @@ import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
import { Reference } from "@/reference/reference"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string }
export const Info = Schema.Struct({
name: Schema.String,
@@ -301,70 +303,69 @@ export const layer = Layer.effect(
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
function referencePrompt(reference: Reference.Resolved) {
function referencePath(value: string) {
if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2))
return path.isAbsolute(value)
? value
: path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value)
}
function resolveReference(reference: ReferenceEntry): ResolvedReference {
if (typeof reference === "string") {
if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) {
return { kind: "local", path: referencePath(reference) }
}
return { kind: "git", repository: reference }
}
if ("path" in reference) return { kind: "local", path: referencePath(reference.path) }
return { kind: "git", repository: reference.repository, branch: reference.branch }
}
function referencePrompt(name: string, reference: ResolvedReference) {
if (reference.kind === "local") {
return [
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`,
`Local directory: ${reference.path}`,
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n")
}
if (reference.kind === "invalid") {
return [
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
`Configured repository: ${reference.repository}`,
`Problem: ${reference.message}`,
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
`When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
].join("\n\n")
}
return [
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
PROMPT_SCOUT,
`You are Scout reference @${name}. This reference points to a git repository.`,
`Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`Cached directory: ${reference.path}`,
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
`When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`,
].join("\n\n")
}
function referenceDescription(reference: Reference.Resolved) {
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
return `Invalid Scout reference for repository ${reference.repository}`
}
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
const resolvedReferences = Reference.resolveAll({
references: cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
})
for (const resolved of resolvedReferences) {
if (agents[resolved.name]) continue
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
agents[resolved.name] = {
name: resolved.name,
description: referenceDescription(resolved),
for (const [name, reference] of Object.entries(cfg.reference ?? {})) {
if (agents[name]) continue
const resolved = resolveReference(reference)
const localPath = resolved.kind === "local" ? resolved.path : undefined
agents[name] = {
name,
description:
resolved.kind === "local"
? `Scout reference for local directory ${resolved.path}`
: `Scout reference for repository ${resolved.repository}`,
permission: Permission.merge(
agents.scout.permission,
Permission.fromConfig({
repo_clone: "deny",
...(localPath
Permission.fromConfig(
localPath
? {
external_directory: {
[localPath]: "allow",
[path.join(localPath, "*")]: "allow",
},
}
: {}),
}),
: {},
),
),
prompt: referencePrompt(resolved),
options: { reference: cfg.reference?.[resolved.name], resolved },
prompt: referencePrompt(name, resolved),
options: { reference },
mode: "subagent",
native: false,
}
@@ -1,33 +0,0 @@
import type { Permission } from "../permission"
import type { Agent } from "./agent"
/**
* Build the `permission` ruleset for a subagent's session when it's spawned
* via the task tool. Combines:
*
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
* restrictions live on the agent ruleset, not on the session, so a
* subagent that only inherited the parent SESSION's permission would
* silently bypass them. (#26514)
* 2. The parent **session's** deny rules and external_directory rules —
* same forwarding the original code already did.
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
return [
...parentAgentDenies,
...input.parentSessionPermission.filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
]
}
-5
View File
@@ -2,8 +2,3 @@ declare module "*.wav" {
const file: string
export default file
}
declare module "*.wasm" {
const file: string
export default file
}
@@ -1,181 +0,0 @@
export const SAMPLE_MARKDOWN = [
"# Direct Mode Demo",
"",
"This is a realistic assistant response for direct-mode formatting checks.",
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
"",
"## Summary",
"",
"- Restored the final markdown flush so the last block is committed on idle.",
"- Switched markdown scrollback commits back to top-level block boundaries.",
"- Added footer-level regression coverage for split-footer rendering.",
"",
"## Status",
"",
"| Area | Before | After | Notes |",
"| --- | --- | --- | --- |",
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
"",
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
"",
"```ts",
"const result = { markdown: true, tables: 2, stable: true }",
"```",
"",
"## Files",
"",
"| File | Change |",
"| --- | --- |",
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
"",
"Next step: run `/fmt table` if you want a tighter table-only sample.",
].join("\n")
export const SAMPLE_TABLE = [
"# Table Sample",
"",
"| Kind | Example | Notes |",
"| --- | --- | --- |",
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
"| Unicode | `漢字` | Wide characters should remain aligned |",
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
"| Status | done | Final row should still appear after idle |",
].join("\n")
export const MARKDOWN_PATTERNS = {
"md-code": [
"# Interleaved Code",
"",
"Start with a short conclusion before any code appears.",
"",
"```ts",
"export function parse(input: string) {",
" return input.trim().split(/\\s+/)",
"}",
"```",
"",
"Then continue with prose immediately after the code block. This should not inherit code styling or indentation.",
"",
"```tsx",
"<Show when={props.enabled}>",
" <markdown content={props.text} streaming />",
"</Show>",
"```",
"",
"Final paragraph after a second fence with `inline code`, **bold text**, and _emphasis_ mixed together.",
].join("\n"),
"md-fence": [
"# Fence Boundaries",
"",
"The renderer should recover cleanly around multiple fences and nearby paragraphs.",
"",
"```bash",
"bun run test -- --grep markdown",
"```",
"Text directly after a fence.",
"```json",
"{",
' "status": "ok",',
' "items": ["one", "two"]',
"}",
"```",
"Trailing paragraph after JSON. The next fence intentionally has no language.",
"```",
"plain fenced text",
"with multiple lines",
"```",
].join("\n"),
"md-list": [
"# Lists With Code",
"",
"1. First ordered item with `inline code`.",
"2. Second ordered item before a nested list:",
" - Nested bullet with a long phrase that should wrap without swallowing the marker or changing indentation.",
" - Nested bullet before fenced code:",
"",
" ```ts",
" const nested = true",
" ```",
"",
"3. Third ordered item after the nested fence.",
"",
"- Top-level bullet after ordered list.",
"- Another bullet with a paragraph below.",
"",
" Continuation paragraph should stay associated with the bullet without becoming code.",
].join("\n"),
"md-table-code": [
"# Tables And Code",
"",
"| Case | Input | Expected |",
"| --- | --- | --- |",
"| Inline code | `const x = 1` | stays inline |",
"| Escaped pipe | `A\\|B` | one cell |",
"| Long token | `LongTokenWithoutNaturalBreaks_1234567890_abcdefghijklmnopqrstuvwxyz` | wraps or scrolls predictably |",
"",
"A code block follows the table:",
"",
"```ts",
"const rows = [",
' { case: "inline code", expected: "stays inline" },',
' { case: "escaped pipe", expected: "one cell" },',
"]",
"```",
"",
"And then another compact table:",
"",
"| A | B |",
"| - | - |",
"| done | yes |",
].join("\n"),
"md-inline": [
"# Inline Markdown",
"",
"This paragraph mixes [a normal link](https://opencode.ai), `https://example.com/code-link`, `inline code`, **strong**, _emphasis_, and ~~strikethrough~~.",
"",
"> Blockquote with `inline code` and [a link](https://example.com) should keep quote styling while wrapping.",
"",
"A horizontal rule follows.",
"",
"---",
"",
"After the rule, text should resume normal spacing.",
].join("\n"),
"md-kitchen": [
"# Markdown Kitchen Sink",
"",
"This combines headings, paragraphs, lists, blockquotes, tables, inline code, and multiple code fences.",
"",
"## Steps",
"",
"1. Read the response.",
"2. Notice `inline code` before a block.",
"",
"```ts",
"type Result = { ok: boolean; reason?: string }",
"const result: Result = { ok: true }",
"```",
"",
"3. Continue the list after the block.",
"",
"> Quoted note after the list. It should not merge into the previous item.",
"",
"| Feature | Stress |",
"| --- | --- |",
"| Markdown | prose/code/table interleave |",
"| Renderer | wrapping and spacing |",
"",
"```diff",
"- const renderer = oldMarkdown",
"+ const renderer = experimentalMarkdown",
"```",
"",
"Final paragraph with [docs](https://opencode.ai/docs) and `https://example.com/from-code`.",
].join("\n"),
} as const
export const MARKDOWN_PATTERN_KINDS = Object.keys(MARKDOWN_PATTERNS) as Array<keyof typeof MARKDOWN_PATTERNS>
+47 -11
View File
@@ -19,11 +19,9 @@ import type { Event, ToolPart } from "@opencode-ai/sdk/v2"
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
import { writeSessionOutput } from "./stream"
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
import { MARKDOWN_PATTERN_KINDS, MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
const KINDS = [
"markdown",
...MARKDOWN_PATTERN_KINDS,
"table",
"text",
"reasoning",
@@ -53,9 +51,53 @@ function questionKind(value: string | undefined): QuestionKind | undefined {
return QUESTIONS.find((item) => item === next)
}
function markdownPattern(value: string): keyof typeof MARKDOWN_PATTERNS | undefined {
if (value in MARKDOWN_PATTERNS) return value as keyof typeof MARKDOWN_PATTERNS
}
const SAMPLE_MARKDOWN = [
"# Direct Mode Demo",
"",
"This is a realistic assistant response for direct-mode formatting checks.",
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
"",
"## Summary",
"",
"- Restored the final markdown flush so the last block is committed on idle.",
"- Switched markdown scrollback commits back to top-level block boundaries.",
"- Added footer-level regression coverage for split-footer rendering.",
"",
"## Status",
"",
"| Area | Before | After | Notes |",
"| --- | --- | --- | --- |",
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
"",
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
"",
"```ts",
"const result = { markdown: true, tables: 2, stable: true }",
"```",
"",
"## Files",
"",
"| File | Change |",
"| --- | --- |",
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
"",
"Next step: run `/fmt table` if you want a tighter table-only sample.",
].join("\n")
const SAMPLE_TABLE = [
"# Table Sample",
"",
"| Kind | Example | Notes |",
"| --- | --- | --- |",
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
"| Unicode | `漢字` | Wide characters should remain aligned |",
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
"| Status | done | Final row should still appear after idle |",
].join("\n")
type Ref = {
msg: string
@@ -989,12 +1031,6 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
return true
}
const pattern = markdownPattern(kind)
if (pattern) {
await emitText(state, body || MARKDOWN_PATTERNS[pattern], signal)
return true
}
if (kind === "table") {
await emitText(state, body || SAMPLE_TABLE, signal)
return true
+3 -7
View File
@@ -14,7 +14,7 @@
// 4. runs the prompt queue until the footer closes.
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import type { createRunDemo } from "./demo"
import { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
@@ -135,10 +135,6 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
}
async function createDemo(input: Parameters<typeof createRunDemo>[0]) {
return (await import("./demo")).createRunDemo(input)
}
async function resolveExitTitle(
ctx: BootContext,
input: RunRuntimeInput,
@@ -429,7 +425,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
if (input.demo) {
await ensureSession()
state.demo = await createDemo({
state.demo = createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
@@ -552,7 +548,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
state.history = []
includeFiles = true
state.demo = input.demo
? await createDemo({
? createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
@@ -1,34 +0,0 @@
/**
* Aggregate Promise.allSettled results into a single Error that names every
* failed endpoint, or return null when all fulfilled. Used at TUI bootstrap
* boundaries so a single 4xx doesn't drown its parallel siblings as
* unhandled rejections — every failure surfaces in one labeled message.
*/
export type LabeledSettled = {
name: string
result: PromiseSettledResult<unknown>
}
export function aggregateFailures(labeled: LabeledSettled[]): Error | null {
const failed = labeled.filter(
(x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected",
)
if (failed.length === 0) return null
const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ")
const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}`
const err = new Error(summary)
err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) }
return err
}
function reasonMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message
if (typeof reason === "string") return reason
if (reason && typeof reason === "object") {
const obj = reason as { message?: unknown; name?: unknown }
if (typeof obj.message === "string") return obj.message
if (typeof obj.name === "string") return obj.name
}
return String(reason)
}
@@ -15,13 +15,17 @@ export function useEvent() {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
return
}
if (project.workspace.current()) {
// Workspace-scoped events match on workspace identity. Events without a
// workspace label fall through to the directory check — a session with
// no workspaceID can be live in the same directory as the TUI even when
// the TUI itself is attached to a workspace (#26671).
if (event.workspace !== undefined) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
@@ -1,39 +0,0 @@
import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js"
import { Global } from "@opencode-ai/core/global"
const context = createContext<{
path: () => string
format: (input?: string) => string
}>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
return (
<context.Provider
value={{ path: () => props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}
>
{props.children}
</context.Provider>
)
}
export function usePathFormatter() {
const value = useContext(context)
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
return value
}
function formatPath(input: string | undefined, base: string | undefined) {
if (!input) return ""
const root = base || process.cwd()
const absolute = path.isAbsolute(input) ? input : path.resolve(root, input)
const relative = path.relative(root, absolute)
if (!relative) return "."
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) {
return absolute.replace(Global.Path.home, "~")
}
return absolute
}
@@ -32,7 +32,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
import { aggregateFailures } from "./aggregate-failures"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -392,23 +391,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
{ name: "config.providers", promise: providersPromise },
{ name: "provider.list", promise: providerListPromise },
{ name: "app.agents", promise: agentsPromise },
{ name: "config.get", promise: configPromise },
{ name: "project.sync", promise: projectPromise },
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
const blockingRequests: Promise<unknown>[] = [
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
]
await Promise.allSettled(blockingRequests.map((r) => r.promise))
.then((settled) => {
// Surface every failed endpoint in one labeled message instead of
// letting the first rejection drown its siblings as unhandled
// rejections.
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
if (failure) throw failure
})
await Promise.all(blockingRequests)
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
@@ -962,8 +962,6 @@ function getSyntaxRules(theme: Theme) {
style: {
foreground: theme.markdownHeading,
bold: true,
italic: true,
underline: true,
},
},
{
@@ -1177,7 +1175,6 @@ function getSyntaxRules(theme: Theme) {
scope: ["markup.strikethrough"],
style: {
foreground: theme.textMuted,
strikethrough: true,
},
},
{
-285
View File
@@ -1,285 +0,0 @@
import type {
Agent,
AssistantMessage,
Config,
Message,
Model,
Part,
Path,
Project,
Provider,
Session,
} from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { MARKDOWN_PATTERNS, SAMPLE_MARKDOWN, SAMPLE_TABLE } from "../demo-fixtures"
const sessionID = "demo_tui_markdown"
const userMessageID = "demo_tui_user"
const assistantMessageID = "demo_tui_assistant"
const now = Date.now()
const markdown = [
"# Fullscreen TUI Markdown Demo",
"",
"This fake assistant response runs through the fullscreen session timeline without calling an LLM.",
"Use it to compare spacing, wrapping, code fence boundaries, table behavior, and inline markdown rendering.",
"",
"## Baseline",
"",
SAMPLE_MARKDOWN,
"",
"## Table Baseline",
"",
SAMPLE_TABLE,
"",
...Object.entries(MARKDOWN_PATTERNS).flatMap(([name, value]) => ["## " + name, "", value, ""]),
].join("\n")
const model = {
id: "demo",
providerID: "demo",
api: {
id: "demo",
url: "https://example.com/demo",
npm: "demo",
},
name: "Demo",
capabilities: {
temperature: false,
reasoning: true,
attachment: false,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: true,
},
cost: {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
limit: {
context: 128_000,
output: 16_000,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
} satisfies Model
const provider = {
id: "demo",
name: "Demo",
source: "custom",
env: [],
options: {},
models: {
demo: model,
},
} satisfies Provider
const agent = {
name: "build",
description: "Demo agent",
mode: "primary",
native: true,
permission: [],
model: {
providerID: "demo",
modelID: "demo",
},
options: {},
} satisfies Agent
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
},
})
}
export function createTuiDemo(input: { directory: string }) {
const paths = {
home: process.env.HOME ?? input.directory,
state: input.directory,
config: input.directory,
worktree: input.directory,
directory: input.directory,
} satisfies Path
const project = {
id: "demo_project",
worktree: input.directory,
vcs: "git",
name: "Markdown Demo",
time: {
created: now,
updated: now,
},
sandboxes: [],
} satisfies Project
const session = {
id: sessionID,
slug: "markdown-demo",
projectID: project.id,
directory: input.directory,
title: "Markdown Rendering Demo",
agent: agent.name,
model: {
id: model.id,
providerID: provider.id,
},
version: "demo",
time: {
created: now,
updated: now + 2,
},
} satisfies Session
const user = {
id: userMessageID,
sessionID,
role: "user",
time: {
created: now,
},
agent: agent.name,
model: {
providerID: provider.id,
modelID: model.id,
},
} satisfies Message
const assistant = {
id: assistantMessageID,
sessionID,
role: "assistant",
time: {
created: now + 1,
completed: now + 2,
},
parentID: userMessageID,
modelID: model.id,
providerID: provider.id,
mode: "demo",
agent: agent.name,
path: {
cwd: input.directory,
root: input.directory,
},
cost: 0,
tokens: {
input: 120,
output: 3_200,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
} satisfies AssistantMessage
const messages = [
{
info: user,
parts: [
{
id: "demo_tui_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Show me the fullscreen TUI markdown rendering stress cases.",
time: {
start: now,
end: now,
},
},
],
},
{
info: assistant,
parts: [
{
id: "demo_tui_assistant_text",
sessionID,
messageID: assistantMessageID,
type: "text",
text: markdown,
time: {
start: now + 1,
end: now + 2,
},
},
],
},
] satisfies Array<{ info: Message; parts: Part[] }>
const fetch = (async (...args: Parameters<typeof globalThis.fetch>) => {
const request = new Request(args[0], args[1])
const url = new URL(request.url)
const pathname = url.pathname
if (request.method === "GET" && pathname === "/path") return json(paths)
if (request.method === "GET" && pathname === "/project/current") return json(project)
if (request.method === "GET" && pathname === "/config/providers") {
return json({ providers: [provider], default: { build: "demo/demo" } })
}
if (request.method === "GET" && pathname === "/provider") {
return json({ all: [provider], default: { build: "demo/demo" }, connected: [provider.id] })
}
if (request.method === "GET" && pathname === "/experimental/console") {
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
}
if (request.method === "GET" && pathname === "/agent") return json([agent])
if (request.method === "GET" && pathname === "/config") {
return json({ model: "demo/demo", default_agent: agent.name } satisfies Config)
}
if (request.method === "GET" && pathname === "/session") return json([session])
if (request.method === "GET" && pathname === "/command") return json([])
if (request.method === "GET" && pathname === "/lsp") return json([])
if (request.method === "GET" && pathname === "/mcp") return json({})
if (request.method === "GET" && pathname === "/experimental/resource") return json({})
if (request.method === "GET" && pathname === "/formatter") return json([])
if (request.method === "GET" && pathname === "/session/status") return json({ [sessionID]: { type: "idle" } })
if (request.method === "GET" && pathname === "/provider/auth") return json({})
if (request.method === "GET" && pathname === "/vcs") return json({ branch: "demo", default_branch: "dev" })
if (request.method === "GET" && pathname === "/experimental/workspace") return json([])
if (request.method === "GET" && pathname === "/experimental/workspace/status") return json([])
if (request.method === "GET" && pathname === `/session/${sessionID}`) return json(session)
if (request.method === "GET" && pathname === `/session/${sessionID}/message`) return json(messages)
if (request.method === "GET" && pathname === `/session/${sessionID}/todo`) return json([])
if (request.method === "GET" && pathname === `/session/${sessionID}/diff`) return json([])
if (request.method === "GET" && pathname === `/session/${sessionID}/children`) return json([])
return json({ message: `Unhandled demo endpoint: ${request.method} ${pathname}` }, 404)
}) as typeof globalThis.fetch
const events = {
subscribe: async () => () => {},
} satisfies EventSource
return {
sessionID,
fetch,
events,
}
}
@@ -21,20 +21,7 @@ import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import {
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
TextAttributes,
RGBA,
type MarkdownOptions,
type MarkdownRenderable,
CodeRenderable,
TextTableRenderable,
TextRenderable,
StyledText,
type TextChunk,
} from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import type {
AssistantMessage,
@@ -88,6 +75,7 @@ import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
@@ -102,7 +90,6 @@ import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
import { PathFormatterProvider, usePathFormatter } from "../../context/path-format"
addDefaultParsers(parsers.parsers)
@@ -1091,201 +1078,199 @@ export function Session() {
createEffect(on(() => route.sessionID, toBottom))
return (
<PathFormatterProvider path={session()?.directory}>
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<box height={1} />
<For each={messages()}>
{(message, index) => (
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
<context.Provider
value={{
get width() {
return contentWidth()
},
sessionID: route.sessionID,
conceal,
showThinking,
showTimestamps,
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.backgroundElement,
foregroundColor: theme.border,
},
}}
stickyScroll={true}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<box height={1} />
<For each={messages()}>
{(message, index) => (
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
const handleUnrevert = async () => {
const confirmed = await DialogConfirm.show(
dialog,
"Confirm Redo",
"Are you sure you want to restore the reverted messages?",
)
if (confirmed) {
command.run("session.redo")
}
}
return (
<box
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={handleUnrevert}
marginTop={1}
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
</text>
<Show when={revert()!.diffFiles?.length}>
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text fg={theme.text}>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
</Show>
</text>
)}
</For>
</box>
</Show>
</box>
</box>
const handleUnrevert = async () => {
const confirmed = await DialogConfirm.show(
dialog,
"Confirm Redo",
"Are you sure you want to restore the reverted messages?",
)
})()}
</Match>
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
<></>
</Match>
<Match when={message.role === "user"}>
<UserMessage
index={index()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={message.id}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
}}
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}
pending={pending()}
/>
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
</Match>
</Switch>
)}
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
<Show when={visible()}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={visible()}
disabled={disabled()}
on_submit={toBottom}
ref={bind}
>
<Prompt
visible={visible()}
ref={bind}
disabled={disabled()}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</TuiPluginRuntime.Slot>
</Show>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
if (confirmed) {
command.run("session.redo")
}
}
return (
<box
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={handleUnrevert}
marginTop={1}
flexShrink={0}
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.backgroundPanel}
>
<box
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to restore
</text>
<Show when={revert()!.diffFiles?.length}>
<box marginTop={1}>
<For each={revert()!.diffFiles}>
{(file) => (
<text fg={theme.text}>
{file.filename}
<Show when={file.additions > 0}>
<span style={{ fg: theme.diffAdded }}> +{file.additions}</span>
</Show>
<Show when={file.deletions > 0}>
<span style={{ fg: theme.diffRemoved }}> -{file.deletions}</span>
</Show>
</text>
)}
</For>
</box>
</Show>
</box>
</box>
)
})()}
</Match>
<Match when={revert()?.messageID && message.id >= revert()!.messageID}>
<></>
</Match>
<Match when={message.role === "user"}>
<UserMessage
index={index()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={message.id}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
}}
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}
pending={pending()}
/>
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
/>
</Match>
</Switch>
)}
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Show when={session()?.parentID}>
<SubagentFooter />
</Show>
<Show when={visible()}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
session_id={route.sessionID}
visible={visible()}
disabled={disabled()}
on_submit={toBottom}
ref={bind}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
<Prompt
visible={visible()}
ref={bind}
disabled={disabled()}
onSubmit={() => {
toBottom()
}}
sessionID={route.sessionID}
right={<TuiPluginRuntime.Slot name="session_prompt_right" session_id={route.sessionID} />}
/>
</TuiPluginRuntime.Slot>
</Show>
</box>
</Show>
<Toast />
</box>
</context.Provider>
</PathFormatterProvider>
<Show when={sidebarVisible()}>
<Switch>
<Match when={wide()}>
<Sidebar sessionID={route.sessionID} />
</Match>
<Match when={!wide()}>
<box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="flex-end"
backgroundColor={RGBA.fromInts(0, 0, 0, 70)}
>
<Sidebar sessionID={route.sessionID} />
</box>
</Match>
</Switch>
</Show>
</box>
</context.Provider>
)
}
@@ -1538,180 +1523,15 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
const text = createMemo(() => props.part.text.trim())
const diffCache = new Map<string, TextChunk[]>()
const colorDiffChunks = (text: string) => {
const key = `${theme.diffAdded.toString()}:${theme.diffRemoved.toString()}:${text}`
const cached = diffCache.get(key)
if (cached) return cached
let line: "added" | "removed" | undefined
let start = true
const chunks = (text.match(/[^\n]+|\n/g) ?? [text]).map((part): TextChunk => {
if (start && part !== "\n") {
line = part.startsWith("+") ? "added" : part.startsWith("-") ? "removed" : undefined
start = false
}
const next = {
__isChunk: true,
text: part,
...(line === "added" ? { fg: theme.diffAdded } : {}),
...(line === "removed" ? { fg: theme.diffRemoved } : {}),
} satisfies TextChunk
if (part === "\n") {
line = undefined
start = true
}
return next
})
diffCache.set(key, chunks)
if (diffCache.size > 20) diffCache.delete(diffCache.keys().next().value!)
return chunks
}
const renderBlockquoteBar = (chunks: TextChunk[]) => {
let lineStart = true
let spaces = 0
let replaced = false
let skipWhitespace = false
return chunks.flatMap((chunk) => {
const result: TextChunk[] = []
let next = ""
const flush = () => {
if (!next) return
result.push(next === chunk.text ? chunk : { ...chunk, text: next })
next = ""
}
for (const char of chunk.text) {
if (skipWhitespace && (char === " " || char === "\t")) {
skipWhitespace = false
continue
}
skipWhitespace = false
if (lineStart && !replaced && char === " " && spaces < 3) {
spaces++
next += char
continue
}
if (lineStart && !replaced && char === ">") {
flush()
result.push({ __isChunk: true, text: "│ ", fg: theme.textMuted, attributes: TextAttributes.NONE })
replaced = true
skipWhitespace = true
continue
}
next += char
if (char === "\n") {
lineStart = true
spaces = 0
replaced = false
skipWhitespace = false
continue
}
lineStart = false
}
flush()
return result
})
}
const trimCodeIndent = (value: string) => {
const lines = value.split("\n")
const indents = lines.filter((line) => line.trim()).map((line) => line.match(/^[ \t]*/)?.[0].length ?? 0)
const indent = Math.min(...indents)
if (!Number.isFinite(indent) || indent === 0) return value
return lines.map((line) => (line.trim() ? line.slice(indent) : line)).join("\n")
}
const padTableCells = (renderable: TextTableRenderable) => {
renderable.content = renderable.content.map((row) =>
row.map((cell) => {
const content = cell ?? []
return [{ __isChunk: true, text: " " }, ...content, { __isChunk: true, text: " " }] satisfies TextChunk[]
}),
)
}
const configureMarkdown = (node: MarkdownRenderable | undefined) => {
if (!node) return
const renderNode: NonNullable<MarkdownOptions["renderNode"]> = (token, context) => {
const content = text()
const firstBlock = content.startsWith(token.raw.trimStart())
if (token.type === "hr") {
return new BoxRenderable(node.ctx, {
width: "100%",
height: 1,
border: ["top"],
borderColor: theme.border,
flexShrink: 0,
})
}
if (token.type === "blockquote") {
const renderable = context.defaultRender()
if (renderable instanceof CodeRenderable) {
const code = renderable
const onChunks = code.onChunks
code.onChunks = (chunks, context) => {
const result = onChunks?.call(code, chunks, context)
if (result instanceof Promise) return result.then((next) => renderBlockquoteBar(next ?? chunks))
return renderBlockquoteBar(result ?? chunks)
}
}
if (!firstBlock && renderable) {
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
}
return renderable
}
const needsCodeTopGap = token.type === "code" && !firstBlock
if (token.type === "code" && /^[ \t]{4,}(```|~~~)/.test(token.raw)) {
token.text = trimCodeIndent(token.text)
}
if (token.type === "table") {
const renderable = context.defaultRender()
if (renderable instanceof TextTableRenderable) padTableCells(renderable)
return renderable
}
if (token.type === "code" && token.lang?.trim().toLowerCase() === "diff") {
const renderable = new TextRenderable(node.ctx, {
content: new StyledText(colorDiffChunks(token.text)),
width: "100%",
flexShrink: 0,
})
if (needsCodeTopGap) renderable.marginTop = 1
return renderable
}
const renderable = context.defaultRender()
if (token.type === "heading" && token.depth === 1 && !firstBlock && renderable) {
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 2) : 2
}
if (needsCodeTopGap && renderable) {
renderable.marginTop = typeof renderable.marginTop === "number" ? Math.max(renderable.marginTop, 1) : 1
}
return renderable
}
// OpenTUI Solid constructs elements with only `{ id }`, so constructor-only
// MarkdownOptions need to be installed on the renderable directly.
const target = node as unknown as {
_internalBlockMode: "top-level"
_renderNode: typeof renderNode
}
target._internalBlockMode = "top-level"
target._renderNode = renderNode
}
return (
<Show when={text()}>
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<Switch>
<Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
<markdown
syntaxStyle={syntax()}
streaming={true}
ref={configureMarkdown}
tableOptions={{ style: "grid", widthMode: "content" }}
content={text()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
@@ -1723,7 +1543,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
drawUnstyledText={false}
streaming={true}
syntaxStyle={syntax()}
content={text()}
content={props.part.text.trim()}
conceal={ctx.conceal()}
fg={theme.text}
/>
@@ -2007,7 +1827,7 @@ function BlockTool(props: {
function Shell(props: ToolProps<typeof ShellTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const sync = useSync()
const isRunning = createMemo(() => props.part.state.status === "running")
const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
const [expanded, setExpanded] = createSignal(false)
@@ -2021,7 +1841,18 @@ function Shell(props: ToolProps<typeof ShellTool>) {
const workdirDisplay = createMemo(() => {
const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined
return pathFormatter.format(workdir)
const base = sync.path.directory
if (!base) return undefined
const absolute = path.resolve(base, workdir)
if (absolute === base) return undefined
const home = Global.Path.home
if (!home) return absolute
const match = absolute === home || absolute.startsWith(home + path.sep)
return match ? absolute.replace(home, "~") : absolute
})
const title = createMemo(() => {
@@ -2063,7 +1894,6 @@ function Shell(props: ToolProps<typeof ShellTool>) {
function Write(props: ToolProps<typeof WriteTool>) {
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const code = createMemo(() => {
if (!props.input.content) return ""
return props.input.content
@@ -2072,7 +1902,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool title={"# Wrote " + pathFormatter.format(props.input.filePath)} part={props.part}>
<BlockTool title={"# Wrote " + normalizePath(props.input.filePath!)} part={props.part}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
@@ -2087,7 +1917,7 @@ function Write(props: ToolProps<typeof WriteTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing write..." complete={props.input.filePath} part={props.part}>
Write {pathFormatter.format(props.input.filePath)}
Write {normalizePath(props.input.filePath!)}
</InlineTool>
</Match>
</Switch>
@@ -2095,10 +1925,9 @@ function Write(props: ToolProps<typeof WriteTool>) {
}
function Glob(props: ToolProps<typeof GlobTool>) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.count}>
({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
</Show>
@@ -2108,7 +1937,6 @@ function Glob(props: ToolProps<typeof GlobTool>) {
function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme()
const pathFormatter = usePathFormatter()
const isRunning = createMemo(() => props.part.state.status === "running")
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
@@ -2126,13 +1954,13 @@ function Read(props: ToolProps<typeof ReadTool>) {
spinner={isRunning()}
part={props.part}
>
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {pathFormatter.format(filepath)}
Loaded {normalizePath(filepath)}
</text>
</box>
)}
@@ -2142,10 +1970,9 @@ function Read(props: ToolProps<typeof ReadTool>) {
}
function Grep(props: ToolProps<typeof GrepTool>) {
const pathFormatter = usePathFormatter()
return (
<InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {pathFormatter.format(props.input.path)} </Show>
Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
<Show when={props.metadata.matches}>
({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
</Show>
@@ -2244,7 +2071,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
function Edit(props: ToolProps<typeof EditTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style
@@ -2260,7 +2086,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
return (
<Switch>
<Match when={props.metadata.diff !== undefined}>
<BlockTool title={"← Edit " + pathFormatter.format(props.input.filePath)} part={props.part}>
<BlockTool title={"← Edit " + normalizePath(props.input.filePath!)} part={props.part}>
<box paddingLeft={1}>
<diff
diff={diffContent()}
@@ -2287,7 +2113,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })}
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
</InlineTool>
</Match>
</Switch>
@@ -2297,7 +2123,6 @@ function Edit(props: ToolProps<typeof EditTool>) {
function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const ctx = use()
const { theme, syntax } = useTheme()
const pathFormatter = usePathFormatter()
const files = createMemo(() => props.metadata.files ?? [])
@@ -2336,7 +2161,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
if (file.type === "delete") return "# Deleted " + file.relativePath
if (file.type === "add") return "# Created " + file.relativePath
if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath
if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
return "← Patched " + file.relativePath
}
@@ -2456,6 +2281,20 @@ function Diagnostics(props: { diagnostics?: Record<string, Record<string, any>[]
)
}
function normalizePath(input?: string) {
if (!input) return ""
const cwd = process.cwd()
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const relative = path.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
// outside cwd - use absolute
return absolute
}
function input(input: Record<string, any>, omit?: string[]): string {
const primitives = Object.entries(input).filter(([key, value]) => {
if (omit?.includes(key)) return false
@@ -11,16 +11,34 @@ import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id"
import { webSearchProviderLabel } from "@/tool/websearch"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
type PermissionStage = "permission" | "always" | "reject"
function normalizePath(input?: string) {
if (!input) return ""
const cwd = process.cwd()
const home = Global.Path.home
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
const relative = path.relative(cwd, absolute)
if (!relative) return "."
if (!relative.startsWith("..")) return relative
// outside cwd - use ~ or absolute
if (home && (absolute === home || absolute.startsWith(home + path.sep))) {
return absolute.replace(home, "~")
}
return absolute
}
function filetype(input?: string) {
if (!input) return "none"
const ext = path.extname(input)
@@ -119,7 +137,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const [store, setStore] = createStore({
stage: "permission" as PermissionStage,
})
const pathFormatter = usePathFormatter()
const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID))
@@ -203,7 +220,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filepath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Edit ${pathFormatter.format(filepath)}`,
title: `Edit ${normalizePath(filepath)}`,
body: <EditBody request={props.request} />,
}
}
@@ -213,11 +230,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const filePath = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `Read ${pathFormatter.format(filePath)}`,
title: `Read ${normalizePath(filePath)}`,
body: (
<Show when={filePath}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(filePath)}</text>
<text fg={theme.textMuted}>{"Path: " + normalizePath(filePath)}</text>
</box>
</Show>
),
@@ -259,11 +276,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
const dir = typeof raw === "string" ? raw : ""
return {
icon: "→",
title: `List ${pathFormatter.format(dir)}`,
title: `List ${normalizePath(dir)}`,
body: (
<Show when={dir}>
<box paddingLeft={1}>
<text fg={theme.textMuted}>{"Path: " + pathFormatter.format(dir)}</text>
<text fg={theme.textMuted}>{"Path: " + normalizePath(dir)}</text>
</box>
</Show>
),
@@ -342,7 +359,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined
const raw = parent ?? filepath ?? derived
const dir = pathFormatter.format(raw)
const dir = normalizePath(raw)
const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string")
return {
+4 -31
View File
@@ -111,10 +111,6 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("demo", {
type: "boolean",
describe: "open a fake fullscreen TUI session for renderer debugging",
}),
handler: async (args) => {
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@@ -134,6 +130,7 @@ export const TuiThreadCommand = cmd({
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
} catch {
@@ -141,32 +138,6 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const config = TuiConfig.get()
config.catch(() => {})
if (args.demo) {
const { createTuiDemo } = await import("./demo")
const { tui } = await import("./app")
const demo = createTuiDemo({ directory: cwd })
await tui({
url: "http://opencode.demo",
config: await config,
directory: cwd,
fetch: demo.fetch,
events: demo.events,
args: {
continue: false,
sessionID: demo.sessionID,
agent: args.agent,
model: args.model,
prompt: await input(args.prompt),
fork: false,
},
})
return
}
const file = await target()
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
@@ -216,6 +187,8 @@ export const TuiThreadCommand = cmd({
}
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
@@ -263,7 +236,7 @@ export const TuiThreadCommand = cmd({
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config: await config,
config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,
@@ -1,30 +0,0 @@
export * as ConfigAttachment from "./attachment"
import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
export const Image = Schema.Struct({
auto_resize: Schema.optional(Schema.Boolean).annotate({
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
}),
max_width: Schema.optional(PositiveInt).annotate({
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
}),
max_height: Schema.optional(PositiveInt).annotate({
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
}),
max_base64_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
}),
})
.annotate({ identifier: "ImageAttachmentConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Image = Schema.Schema.Type<typeof Image>
export const Info = Schema.Struct({
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
})
.annotate({ identifier: "AttachmentConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
-4
View File
@@ -25,7 +25,6 @@ import { containsPath } from "../project/instance-context"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema"
import { ConfigAgent } from "./agent"
import { ConfigAttachment } from "./attachment"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
@@ -242,9 +241,6 @@ export const Info = Schema.Struct({
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(ConfigPermission.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
attachment: Schema.optional(ConfigAttachment.Info).annotate({
description: "Attachment processing configuration, including image size limits and resizing behavior",
}),
enterprise: Schema.optional(
Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
+1 -2
View File
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
import { ModelStatus } from "@/provider/model-status"
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
@@ -50,7 +49,7 @@ export const Model = Schema.Struct({
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(ModelStatus),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
@@ -1,10 +1,12 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } from "@opencode-ai/core/effect-zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
Schema.brand("WorkspaceID"),
)
export type WorkspaceID = typeof workspaceIdSchema.Type
@@ -30,7 +30,6 @@ import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionTimeline } from "@/session/timeline"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
@@ -44,7 +43,6 @@ import { Format } from "@/format"
import { InstanceLayer } from "@/project/instance-layer"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Reference } from "@/reference/reference"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
@@ -86,7 +84,6 @@ export const AppLayer = Layer.mergeAll(
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionTimeline.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
@@ -99,7 +96,6 @@ export const AppLayer = Layer.mergeAll(
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Reference.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Pty.defaultLayer,
+8
View File
@@ -1,3 +1,4 @@
import z from "zod"
import { randomBytes } from "crypto"
const prefixes = {
@@ -6,12 +7,19 @@ const prefixes = {
message: "msg",
permission: "per",
question: "que",
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
entry: "ent",
account: "act",
} as const
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
const LENGTH = 26
// State for monotonic ID generation
-180
View File
@@ -1,180 +0,0 @@
import { Config } from "@/config/config"
import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer, Schema } from "effect"
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
"ImagePhotonUnavailableError",
{},
) {
override get message() {
return "Photon image processor is unavailable"
}
}
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
url: Schema.String,
}) {
override get message() {
return "Image URL must be a base64 data URL"
}
}
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
override get message() {
return "Image could not be decoded"
}
}
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
bytes: Schema.Number,
max: Schema.Number,
width: Schema.Number,
height: Schema.Number,
max_width: Schema.Number,
max_height: Schema.Number,
}) {
override get message() {
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
}
}
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
export interface Interface {
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.promise(async () => {
try {
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } }))
.default
// Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path.
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
photonWasm
return await import("@silvia-odwyer/photon-node")
} catch {
return null
}
}),
)
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
const image = (yield* config.get()).attachment?.image
const info = {
autoResize: image?.auto_resize ?? AUTO_RESIZE,
maxWidth: image?.max_width ?? MAX_WIDTH,
maxHeight: image?.max_height ?? MAX_HEIGHT,
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
}
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
return yield* new InvalidDataUrlError({ url: input.url })
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
const photon = yield* loadPhoton
if (!photon) return yield* new PhotonUnavailableError()
const decoded = yield* Effect.sync(() => {
try {
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
} catch {
return undefined
}
})
if (!decoded) return yield* new DecodeError()
try {
const originalWidth = decoded.get_width()
const originalHeight = decoded.get_height()
if (
originalWidth <= info.maxWidth &&
originalHeight <= info.maxHeight &&
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
)
return input
if (!info.autoResize)
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
max_width: info.maxWidth,
max_height: info.maxHeight,
})
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(originalWidth * scale)),
height: Math.max(1, Math.round(originalHeight * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
}, [])) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
const candidate = [
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
]
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
.find((item) => item.bytes <= info.maxBase64Bytes)
resized.free()
if (candidate) {
log.info("using resized image", {
from_mime: input.mime,
to_mime: candidate.mime,
from: `${originalWidth}x${originalHeight}`,
to: `${size.width}x${size.height}`,
})
return {
...input,
mime: candidate.mime,
url: `data:${candidate.mime};base64,${candidate.data}`,
}
}
}
return yield* new SizeError({
bytes: Buffer.byteLength(base64, "utf8"),
max: info.maxBase64Bytes,
width: originalWidth,
height: originalHeight,
max_width: info.maxWidth,
max_height: info.maxHeight,
})
} finally {
decoded.free()
}
})
return Service.of({ normalize })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
export * as Image from "./image"
-4
View File
@@ -1,4 +0,0 @@
declare module "*.md" {
const content: string
export default content
}
+5 -43
View File
@@ -6,7 +6,6 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ToolSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
@@ -37,15 +36,6 @@ import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
const TolerantListToolsResultSchema = z.looseObject({
tools: z.array(TolerantToolSchema),
nextCursor: z.string().optional(),
})
export const Resource = Schema.Struct({
name: Schema.String,
uri: Schema.String,
@@ -129,38 +119,6 @@ function remoteURL(key: string, value: string) {
log.warn("invalid remote mcp url", { key })
}
function isOutputSchemaValidationError(error: Error) {
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
}
function listTools(key: string, client: MCPClient, timeout: number) {
return Effect.tryPromise({
try: () => client.listTools(undefined, { timeout }),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((error) => {
if (!isOutputSchemaValidationError(error)) return Effect.fail(error)
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
return Effect.tryPromise({
try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) =>
result.tools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
),
)
}),
)
}
// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
const inputSchema = mcpTool.inputSchema
@@ -193,7 +151,11 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
}
function defs(key: string, client: MCPClient, timeout?: number) {
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
return Effect.tryPromise({
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined)
+2 -2
View File
@@ -1,12 +1,12 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } from "@opencode-ai/core/effect-zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { Newtype } from "@opencode-ai/core/schema"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")),
Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
+1 -4
View File
@@ -12,7 +12,6 @@ import { ShareNext } from "@/share/share-next"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { Service } from "./bootstrap-service"
import { Reference } from "@/reference/reference"
export { Service } from "./bootstrap-service"
export type { Interface } from "./bootstrap-service"
@@ -30,7 +29,6 @@ export const layer = Layer.effect(
const lsp = yield* LSP.Service
const plugin = yield* Plugin.Service
const project = yield* Project.Service
const reference = yield* Reference.Service
const shareNext = yield* ShareNext.Service
const snapshot = yield* Snapshot.Service
const vcs = yield* Vcs.Service
@@ -45,7 +43,7 @@ export const layer = Layer.effect(
// Each service self-manages its own slow work via Effect.forkScoped against
// its per-instance state scope. We just await materialization here.
yield* Effect.forEach(
[reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project],
(s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.withSpan("InstanceBootstrap.init"))
@@ -65,7 +63,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
LSP.defaultLayer,
Plugin.defaultLayer,
Project.defaultLayer,
Reference.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
Vcs.defaultLayer,
+4 -4
View File
@@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({
// populates patch, but loosening matches the sibling schema so a
// future code path that omits it can't crash /instance/vcs/diff.
patch: Schema.optional(Schema.String),
additions: Schema.Finite,
deletions: Schema.Finite,
additions: NonNegativeInt,
deletions: NonNegativeInt,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
})
.annotate({ identifier: "VcsFileDiff" })
@@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type<typeof FileDiff>
export const FileStatus = Schema.Struct({
file: Schema.String,
additions: Schema.Finite,
deletions: Schema.Finite,
additions: NonNegativeInt,
deletions: NonNegativeInt,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "VcsFileStatus" })
@@ -1,9 +0,0 @@
import { Schema } from "effect"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
export type ModelStatus = typeof ModelStatus.Type
export * as ProviderModelStatus from "./model-status"
+1 -2
View File
@@ -8,7 +8,6 @@ import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { CatalogModelStatus } from "./model-status"
const Cost = Schema.Struct({
input: Schema.Finite,
@@ -72,7 +71,7 @@ export const Model = Schema.Struct({
),
}),
),
status: Schema.optional(CatalogModelStatus),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
+1 -2
View File
@@ -28,7 +28,6 @@ import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ModelStatus } from "./model-status"
const log = Log.create({ service: "provider" })
@@ -898,7 +897,7 @@ export const Model = Schema.Struct({
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
status: ModelStatus,
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
options: Schema.Record(Schema.String, Schema.Any),
headers: Schema.Record(Schema.String, Schema.String),
release_date: Schema.String,
+2 -2
View File
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } from "@opencode-ai/core/effect-zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
+5 -2
View File
@@ -1,10 +1,13 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } from "@opencode-ai/core/effect-zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { Newtype } from "@opencode-ai/core/schema"
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
export class QuestionID extends Newtype<QuestionID>()(
"QuestionID",
Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }),
) {
static ascending(id?: string): QuestionID {
return this.make(Identifier.ascending("question", id))
}
@@ -1,237 +0,0 @@
import path from "path"
import { Effect, Context, Layer, Scope } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { Git } from "@/git"
import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository"
import { RepositoryCache } from "./repository-cache"
type ReferenceEntry = NonNullable<Config.Info["reference"]>[string]
export type Resolved =
| {
name: string
kind: "local"
path: string
}
| {
name: string
kind: "git"
repository: string
reference: RepositoryReference
path: string
branch?: string
}
| {
name: string
kind: "invalid"
repository: string
message: string
}
type State = {
references: Resolved[]
materializeAll: Effect.Effect<void>
materializeByPath: { path: string; run: Effect.Effect<void> }[]
}
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly list: () => Effect.Effect<Resolved[]>
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
readonly ensure: (target?: string) => Effect.Effect<void>
readonly contains: (target?: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Reference") {}
export function referencePath(input: { directory: string; worktree: string; value: string }) {
if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2))
return path.isAbsolute(input.value)
? input.value
: path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value)
}
function resolveGit(
input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined },
): Resolved {
const parsed = parseRepositoryReference(input.repository)
if (!parsed || parsed.protocol === "file:") {
return {
name: input.name,
kind: "invalid",
repository: input.repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
}
}
return {
name: input.name,
kind: "git",
repository: input.repository,
reference: parsed,
path: repositoryCachePath(parsed),
...("branch" in input ? { branch: input.branch } : {}),
}
}
function branchLabel(branch: string | undefined) {
return branch ?? "default branch"
}
function normalizedTarget(target?: string) {
if (!target) return
return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
}
function containsReferencePath(referencePath: string, target: string) {
return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target)
}
export function resolve(input: {
name: string
reference: ReferenceEntry
directory: string
worktree: string
}): Resolved {
if (typeof input.reference === "string") {
if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) }
}
return resolveGit({ name: input.name, repository: input.reference })
}
if ("path" in input.reference) {
return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) }
}
return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch })
}
export function resolveAll(input: {
references: NonNullable<Config.Info["reference"]>
directory: string
worktree: string
}) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]) => {
const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree })
if (resolved.kind !== "git") return resolved
const existing = seen.get(resolved.path)
if (!existing) {
seen.set(resolved.path, { name, branch: resolved.branch })
return resolved
}
if (existing.branch === resolved.branch) return resolved
return {
name,
kind: "invalid" as const,
repository: resolved.repository,
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`,
}
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const scope = yield* Scope.Scope
const state = yield* InstanceState.make<State>(
Effect.fn("Reference.state")(function* (ctx) {
const cfg = yield* config.get()
const references = resolveAll({
references: cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
})
const seenPath = new Set<string>()
const gitReferences = references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git") return false
if (seenPath.has(reference.path)) return false
seenPath.add(reference.path)
return true
})
const materializeByPath = yield* Effect.forEach(
gitReferences,
Effect.fnUntraced(function* (reference) {
const run = yield* Effect.cached(
RepositoryCache.ensure(
{ reference: reference.reference, branch: reference.branch, refresh: true },
{ fs, git },
).pipe(
Effect.asVoid,
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
),
),
)
return { path: reference.path, run }
}),
{ concurrency: "unbounded" },
)
const materializeAll = yield* Effect.cached(
Flag.OPENCODE_EXPERIMENTAL_SCOUT
? Effect.gen(function* () {
yield* Effect.forEach(
materializeByPath,
Effect.fnUntraced(function* (item) {
yield* item.run
}),
{ concurrency: 4, discard: true },
)
})
: Effect.void,
)
return { references, materializeAll, materializeByPath }
}),
)
return Service.of({
init: Effect.fn("Reference.init")(function* () {
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
}),
list: Effect.fn("Reference.list")(function* () {
return yield* InstanceState.use(state, (s) => s.references)
}),
get: Effect.fn("Reference.get")(function* (name: string) {
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
}),
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return
const full = normalizedTarget(target)
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
return yield* InstanceState.useEffect(
state,
(s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void,
)
}),
contains: Effect.fn("Reference.contains")(function* (target?: string) {
if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false
const full = normalizedTarget(target)
if (!full) return false
return yield* InstanceState.use(state, (s) =>
s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)),
)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Git.defaultLayer),
)
export * as Reference from "./reference"
@@ -1,147 +0,0 @@
import path from "path"
import { Effect } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flock } from "@opencode-ai/core/util/flock"
import { Git } from "@/git"
import {
repositoryCachePath,
sameRepositoryReference,
parseRepositoryReference,
validateRepositoryBranch,
type Reference as RepositoryReference,
} from "@/util/repository"
export type Result = {
repository: string
host: string
remote: string
localPath: string
status: "cached" | "cloned" | "refreshed"
head?: string
branch?: string
}
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false) return "refreshed" as const
if (input.refresh) return "refreshed" as const
return "cached" as const
}
function resetTarget(input: {
requestedBranch?: string
remoteHead: { code: number; stdout: string }
branch: { code: number; stdout: string }
}) {
if (input.requestedBranch) return `origin/${input.requestedBranch}`
if (input.remoteHead.code === 0 && input.remoteHead.stdout) {
return input.remoteHead.stdout.replace(/^refs\/remotes\//, "")
}
if (input.branch.code === 0 && input.branch.stdout) {
return `origin/${input.branch.stdout}`
}
return "HEAD"
}
export const ensure = Effect.fn("RepositoryCache.ensure")(function* (
input: {
reference: RepositoryReference
refresh?: boolean
branch?: string
},
services: {
fs: AppFileSystem.Interface
git: Git.Interface
},
) {
if (input.branch) validateRepositoryBranch(input.branch)
const repository = input.reference.label
const remote = input.reference.remote
const localPath = repositoryCachePath(input.reference)
const cloneTarget = parseRepositoryReference(remote) ?? input.reference
return yield* Effect.acquireUseRelease(
Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })),
() =>
Effect.gen(function* () {
yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie)
const exists = yield* services.fs.existsSafe(localPath)
const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git"))
const origin = hasGitDir
? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath })
: undefined
const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined
const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget))
if (exists && !reuse) {
yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie)
}
const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
if (status === "cloned") {
const clone = yield* services.git.run(
["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath],
{ cwd: path.dirname(localPath) },
)
if (clone.exitCode !== 0) {
throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`)
}
}
if (status === "refreshed") {
const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath })
if (fetch.exitCode !== 0) {
throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`)
}
if (input.branch) {
const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], {
cwd: localPath,
})
if (checkout.exitCode !== 0) {
throw new Error(
checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`,
)
}
}
const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath })
const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath })
const target = resetTarget({
requestedBranch: input.branch,
remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() },
branch: { code: branch.exitCode, stdout: branch.text().trim() },
})
const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath })
if (reset.exitCode !== 0) {
throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`)
}
}
const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath })
const branch = yield* services.git.branch(localPath)
const headText = head.exitCode === 0 ? head.text().trim() : undefined
return {
repository,
host: input.reference.host,
remote,
localPath,
status,
head: headText,
branch,
} satisfies Result
}),
(lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore),
)
})
export * as RepositoryCache from "./repository-cache"
@@ -5,7 +5,6 @@ import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { WorkspaceRoutingQuery } from "./middleware/workspace-routing"
const log = Log.create({ service: "server" })
@@ -17,7 +16,6 @@ export const EventApi = HttpApi.make("event").add(
HttpApiGroup.make("event")
.add(
HttpApiEndpoint.get("subscribe", EventPaths.event, {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
}).annotateMerge(
OpenApi.annotations({
@@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/config"
@@ -13,7 +13,6 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", root, {
query: WorkspaceRoutingQuery,
success: described(Config.Info, "Get config info"),
}).annotateMerge(
OpenApi.annotations({
@@ -23,7 +22,6 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.patch("update", root, {
query: WorkspaceRoutingQuery,
payload: Config.Info,
success: described(Config.Info, "Successfully updated config"),
error: HttpApiError.BadRequest,
@@ -35,7 +33,6 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.get("providers", `${root}/providers`, {
query: WorkspaceRoutingQuery,
success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
@@ -4,17 +4,12 @@ import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@@ -53,6 +48,12 @@ export const ToolListQuery = Schema.Struct({
model: ModelID,
})
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const WorktreeList = Schema.Array(Schema.String)
export const SessionListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
@@ -81,7 +82,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiGroup.make("experimental")
.add(
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
query: WorkspaceRoutingQuery,
success: described(ConsoleStateResponse, "Active Console provider metadata"),
error: HttpApiError.InternalServerError,
}).annotateMerge(
@@ -92,7 +92,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
query: WorkspaceRoutingQuery,
success: described(ConsoleOrgList, "Switchable Console orgs"),
error: HttpApiError.InternalServerError,
}).annotateMerge(
@@ -103,7 +102,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
query: WorkspaceRoutingQuery,
payload: ConsoleSwitchPayload,
success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest,
@@ -127,7 +125,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
query: WorkspaceRoutingQuery,
success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -139,7 +136,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge(
OpenApi.annotations({
@@ -149,7 +145,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Schema.optional(Worktree.CreateInput),
success: described(Worktree.Info, "Worktree created"),
error: HttpApiError.BadRequest,
@@ -161,7 +156,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
payload: Worktree.RemoveInput,
success: described(Schema.Boolean, "Worktree removed"),
error: HttpApiError.BadRequest,
@@ -173,7 +167,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, {
query: WorkspaceRoutingQuery,
payload: Worktree.ResetInput,
success: described(Schema.Boolean, "Worktree reset"),
error: HttpApiError.BadRequest,
@@ -196,7 +189,6 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"),
}).annotateMerge(
OpenApi.annotations({
@@ -5,11 +5,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import { described } from "./metadata"
export const FileQuery = Schema.Struct({
@@ -101,7 +97,6 @@ export const FileApi = HttpApi.make("file")
}),
),
HttpApiEndpoint.get("status", FilePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(File.Info), "File status"),
}).annotateMerge(
OpenApi.annotations({
@@ -8,11 +8,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import { described } from "./metadata"
const PathInfo = Schema.Struct({
@@ -59,7 +55,6 @@ export const InstanceApi = HttpApi.make("instance")
HttpApiGroup.make("instance")
.add(
HttpApiEndpoint.post("dispose", InstancePaths.dispose, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Instance disposed"),
}).annotateMerge(
OpenApi.annotations({
@@ -69,7 +64,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("path", InstancePaths.path, {
query: WorkspaceRoutingQuery,
success: PathInfo,
}).annotateMerge(
OpenApi.annotations({
@@ -80,7 +74,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
query: WorkspaceRoutingQuery,
success: described(Vcs.Info, "VCS info"),
}).annotateMerge(
OpenApi.annotations({
@@ -91,7 +84,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge(
OpenApi.annotations({
@@ -111,7 +103,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, {
query: WorkspaceRoutingQuery,
success: described(
Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })),
"Raw VCS diff",
@@ -124,7 +115,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, {
query: WorkspaceRoutingQuery,
payload: Vcs.ApplyInput,
success: described(Vcs.ApplyResult, "VCS patch applied"),
error: ApiVcsApplyError,
@@ -136,7 +126,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Command.Info), "List of commands"),
}).annotateMerge(
OpenApi.annotations({
@@ -146,7 +135,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("agent", InstancePaths.agent, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Agent.Info), "List of agents"),
}).annotateMerge(
OpenApi.annotations({
@@ -156,7 +144,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("skill", InstancePaths.skill, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Skill.Info), "List of skills"),
}).annotateMerge(
OpenApi.annotations({
@@ -166,7 +153,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(LSP.Status), "LSP server status"),
}).annotateMerge(
OpenApi.annotations({
@@ -176,7 +162,6 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Format.Status), "Formatter status"),
}).annotateMerge(
OpenApi.annotations({
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
export const AddPayload = Schema.Struct({
@@ -42,7 +42,6 @@ export const McpApi = HttpApi.make("mcp")
HttpApiGroup.make("mcp")
.add(
HttpApiEndpoint.get("status", McpPaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"),
}).annotateMerge(
OpenApi.annotations({
@@ -52,7 +51,6 @@ export const McpApi = HttpApi.make("mcp")
}),
),
HttpApiEndpoint.post("add", McpPaths.status, {
query: WorkspaceRoutingQuery,
payload: AddPayload,
success: described(StatusMap, "MCP server added successfully"),
error: HttpApiError.BadRequest,
@@ -65,7 +63,6 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authStart", McpPaths.auth, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthStartResponse, "OAuth flow started"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
@@ -77,7 +74,6 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authCallback", McpPaths.authCallback, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
payload: AuthCallbackPayload,
success: described(MCP.Status, "OAuth authentication completed"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -91,7 +87,6 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(MCP.Status, "OAuth authentication completed"),
error: [UnsupportedOAuthError, HttpApiError.NotFound],
}).annotateMerge(
@@ -103,7 +98,6 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.delete("authRemove", McpPaths.auth, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(AuthRemoveResponse, "OAuth credentials removed"),
error: HttpApiError.NotFound,
}).annotateMerge(
@@ -115,7 +109,6 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("connect", McpPaths.connect, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server connected successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -125,7 +118,6 @@ export const McpApi = HttpApi.make("mcp")
),
HttpApiEndpoint.post("disconnect", McpPaths.disconnect, {
params: { name: Schema.String },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "MCP server disconnected successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/permission"
@@ -18,7 +18,6 @@ export const PermissionApi = HttpApi.make("permission")
HttpApiGroup.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
}).annotateMerge(
OpenApi.annotations({
@@ -29,7 +28,6 @@ export const PermissionApi = HttpApi.make("permission")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/project"
@@ -19,7 +19,6 @@ export const ProjectApi = HttpApi.make("project")
HttpApiGroup.make("project")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Project.Info), "List of projects"),
}).annotateMerge(
OpenApi.annotations({
@@ -29,7 +28,6 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.get("current", `${root}/current`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Current project information"),
}).annotateMerge(
OpenApi.annotations({
@@ -39,7 +37,6 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Project information after git initialization"),
}).annotateMerge(
OpenApi.annotations({
@@ -50,7 +47,6 @@ export const ProjectApi = HttpApi.make("project")
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -5,7 +5,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/provider"
@@ -15,7 +15,6 @@ export const ProviderApi = HttpApi.make("provider")
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Provider.ListResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
@@ -25,7 +24,6 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
query: WorkspaceRoutingQuery,
success: described(ProviderAuth.Methods, "Provider auth methods"),
}).annotateMerge(
OpenApi.annotations({
@@ -36,7 +34,6 @@ export const ProviderApi = HttpApi.make("provider")
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
error: HttpApiError.BadRequest,
@@ -49,7 +46,6 @@ export const ProviderApi = HttpApi.make("provider")
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
error: HttpApiError.BadRequest,
@@ -5,11 +5,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@@ -41,7 +37,6 @@ export const PtyApi = HttpApi.make("pty")
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(ShellItem), "List of shells"),
}).annotateMerge(
OpenApi.annotations({
@@ -51,7 +46,6 @@ export const PtyApi = HttpApi.make("pty")
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Pty.Info), "List of sessions"),
}).annotateMerge(
OpenApi.annotations({
@@ -61,7 +55,6 @@ export const PtyApi = HttpApi.make("pty")
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
query: WorkspaceRoutingQuery,
payload: Pty.CreateInput,
success: described(Pty.Info, "Created session"),
error: HttpApiError.BadRequest,
@@ -74,7 +67,6 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(Pty.Info, "Session info"),
error: ApiNotFoundError,
}).annotateMerge(
@@ -86,7 +78,6 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
payload: Pty.UpdateInput,
success: described(Pty.Info, "Updated session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -99,7 +90,6 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session removed"),
error: ApiNotFoundError,
}).annotateMerge(
@@ -111,7 +101,6 @@ export const PtyApi = HttpApi.make("pty")
),
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID },
query: WorkspaceRoutingQuery,
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, ApiNotFoundError],
}).annotateMerge(
@@ -140,7 +129,6 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
@@ -1,12 +0,0 @@
import { Schema, SchemaGetter } from "effect"
export const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
export const QueryBooleanOpenApi = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
}
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/question"
@@ -19,7 +19,6 @@ export const QuestionApi = HttpApi.make("question")
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Question.Request), "List of pending questions"),
}).annotateMerge(
OpenApi.annotations({
@@ -30,7 +29,6 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Question answered successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -43,7 +41,6 @@ export const QuestionApi = HttpApi.make("question")
),
HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, {
params: { requestID: QuestionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Question rejected successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -10,20 +10,21 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { Schema, Struct } from "effect"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
export const ListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
scope: Schema.optional(Schema.Literals(["project"])),
@@ -115,7 +116,6 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
query: WorkspaceRoutingQuery,
success: described(StatusMap, "Get session status"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -127,7 +127,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Get session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@@ -139,7 +138,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Session.Info), "List of children"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -151,7 +149,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Todo.Info), "Todo list"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -186,7 +183,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(MessageV2.WithParts, "Message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@@ -197,7 +193,6 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
query: WorkspaceRoutingQuery,
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: described(Session.Info, "Successfully created session"),
error: HttpApiError.BadRequest,
@@ -210,7 +205,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@@ -222,7 +216,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Session.Info, "Successfully updated session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -235,7 +228,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ForkPayload,
success: described(Session.Info, "200"),
error: ApiNotFoundError,
@@ -248,7 +240,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Aborted session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -260,7 +251,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: InitPayload,
success: described(Schema.Boolean, "200"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -274,7 +264,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Successfully shared session"),
error: [HttpApiError.InternalServerError, ApiNotFoundError],
}).annotateMerge(
@@ -286,7 +275,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Successfully unshared session"),
error: [HttpApiError.InternalServerError, ApiNotFoundError],
}).annotateMerge(
@@ -298,7 +286,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: SummarizePayload,
success: described(Schema.Boolean, "Summarized session"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -311,7 +298,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -324,7 +310,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(HttpApiSchema.NoContent, "Prompt accepted"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -338,7 +323,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -351,7 +335,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -364,7 +347,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: RevertPayload,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -378,7 +360,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: described(Session.Info, "Updated session"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -390,7 +371,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -404,7 +384,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted message"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -417,7 +396,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Successfully deleted part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -428,7 +406,6 @@ export const SessionApi = HttpApi.make("session")
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/sync"
@@ -46,7 +46,6 @@ export const SyncApi = HttpApi.make("sync")
HttpApiGroup.make("sync")
.add(
HttpApiEndpoint.post("start", SyncPaths.start, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Workspace sync started"),
}).annotateMerge(
OpenApi.annotations({
@@ -56,7 +55,6 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
query: WorkspaceRoutingQuery,
payload: ReplayPayload,
success: described(ReplayResponse, "Replayed sync events"),
error: HttpApiError.BadRequest,
@@ -68,7 +66,6 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("steal", SyncPaths.steal, {
query: WorkspaceRoutingQuery,
payload: SessionPayload,
success: described(SessionPayload, "Session stolen into workspace"),
error: HttpApiError.BadRequest,
@@ -80,7 +77,6 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("history", SyncPaths.history, {
query: WorkspaceRoutingQuery,
payload: HistoryPayload,
success: described(Schema.Array(HistoryEvent), "Sync events"),
error: HttpApiError.BadRequest,
@@ -4,7 +4,7 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@@ -54,7 +54,6 @@ export const TuiApi = HttpApi.make("tui")
HttpApiGroup.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.PromptAppend.properties,
success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest,
@@ -66,7 +65,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Help dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -76,7 +74,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -86,7 +83,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Theme dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -96,7 +92,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Model dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -106,7 +101,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt submitted successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -116,7 +110,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt cleared successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -126,7 +119,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, {
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(Schema.Boolean, "Command executed successfully"),
error: HttpApiError.BadRequest,
@@ -138,7 +130,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.ToastShow.properties,
success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge(
@@ -149,7 +140,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("publish", TuiPaths.publish, {
query: WorkspaceRoutingQuery,
payload: TuiPublishPayload,
success: described(Schema.Boolean, "Event published successfully"),
error: HttpApiError.BadRequest,
@@ -161,7 +151,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.SessionSelect.properties,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
@@ -173,7 +162,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, {
query: WorkspaceRoutingQuery,
success: described(TuiRequestPayload, "Next TUI request"),
}).annotateMerge(
OpenApi.annotations({
@@ -183,7 +171,6 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, {
query: WorkspaceRoutingQuery,
payload: Schema.Unknown,
success: described(Schema.Boolean, "Response submitted successfully"),
}).annotateMerge(
@@ -3,31 +3,46 @@ import { SessionMessage } from "@/v2/session-message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
export const MessagesQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
}),
cursor: Schema.optional(
Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
),
}).annotate({ identifier: "V2SessionMessagesQuery" })
export const MessageGroup = HttpApiGroup.make("v2.message")
.add(
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionID },
query: MessagesQuery,
query: Schema.Union([
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description:
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Message order for the first page. Use desc for newest first or asc for oldest first.",
}),
cursor: Schema.optional(Schema.Never),
}),
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description:
"Maximum number of messages to return. When omitted, the endpoint returns its default page size.",
}),
cursor: Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
order: Schema.optional(Schema.Never),
}),
]).annotate({ identifier: "V2SessionMessagesQuery" }),
success: Schema.Struct({
items: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({
@@ -1,39 +1,67 @@
import { WorkspaceID } from "@/control-plane/schema"
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@/v2/session-message"
import { Prompt } from "@/v2/session-prompt"
import { SessionV2 } from "@/v2/session"
import { Schema } from "effect"
import { Schema, SchemaGetter } from "effect"
import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
import { QueryBoolean } from "../query"
export const SessionsQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
limit: Schema.optional(
Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
cursor: Schema.optional(
Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
}),
),
}).annotate({ identifier: "V2SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.get("sessions", "/api/session", {
query: SessionsQuery,
query: Schema.Union([
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
}),
directory: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
workspace: WorkspaceID.pipe(Schema.optional),
roots: Schema.Literals(["true", "false"])
.pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
.pipe(Schema.optional),
start: Schema.NumberFromString.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
cursor: Schema.optional(Schema.Never),
}),
Schema.Struct({
limit: Schema.optional(
Schema.NumberFromString.check(
Schema.isInt(),
Schema.isGreaterThanOrEqualTo(1),
Schema.isLessThanOrEqualTo(200),
),
).annotate({
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
}),
cursor: Schema.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
order: Schema.optional(Schema.Never),
directory: Schema.optional(Schema.Never),
path: Schema.optional(Schema.Never),
workspace: Schema.optional(Schema.Never),
roots: Schema.optional(Schema.Never),
start: Schema.optional(Schema.Never),
search: Schema.optional(Schema.Never),
}),
]).annotate({ identifier: "V2SessionsQuery" }),
success: Schema.Struct({
items: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({
@@ -54,7 +82,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: Schema.Struct({
prompt: Prompt,
delivery: SessionV2.Delivery.pipe(Schema.optional),
@@ -71,7 +98,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
@@ -84,7 +110,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
@@ -97,7 +122,6 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
.add(
HttpApiEndpoint.get("context", "/api/session/:sessionID/context", {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
success: Schema.Array(SessionMessage.Message),
}).annotateMerge(
OpenApi.annotations({
@@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op
import { ApiVcsApplyError } from "./instance"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/workspace"
@@ -40,7 +40,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
HttpApiGroup.make("workspace")
.add(
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"),
}).annotateMerge(
OpenApi.annotations({
@@ -50,7 +49,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.Info), "Workspaces"),
}).annotateMerge(
OpenApi.annotations({
@@ -60,7 +58,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("create", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest,
@@ -72,7 +69,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
query: WorkspaceRoutingQuery,
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
}).annotateMerge(
OpenApi.annotations({
@@ -82,7 +78,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(
OpenApi.annotations({
@@ -93,7 +88,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
),
HttpApiEndpoint.delete("remove", WorkspacePaths.remove, {
params: { id: Workspace.Info.fields.id },
query: WorkspaceRoutingQuery,
success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -104,7 +98,6 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
query: WorkspaceRoutingQuery,
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
@@ -203,15 +203,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof InitPayload.Type
}) {
yield* promptSvc
.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
})
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
yield* promptSvc.command({
sessionID: ctx.params.sessionID,
messageID: ctx.payload.messageID,
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
command: Command.Default.INIT,
arguments: "",
})
return true
})
@@ -260,19 +258,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
}) {
const instance = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
const message = yield* promptSvc
.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
})
.pipe(
Effect.provideService(InstanceRef, instance),
Effect.provideService(WorkspaceRef, workspace),
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {
contentType: "application/json",
})
return HttpServerResponse.stream(
Stream.fromEffect(
promptSvc
.prompt({
...ctx.payload,
sessionID: ctx.params.sessionID,
})
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
).pipe(
Stream.map((message) => JSON.stringify(message)),
Stream.encodeText,
),
{ contentType: "application/json" },
)
})
const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
@@ -298,9 +297,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
params: { sessionID: SessionID }
payload: typeof CommandPayload.Type
}) {
return yield* promptSvc
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
})
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
@@ -34,7 +34,6 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
return handlers.handle(
"messages",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({})
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
catch: () => new HttpApiError.BadRequest({}),
@@ -22,31 +22,6 @@ type SessionCursor = typeof SessionCursor.Type
const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
function hasCursorFilter(query: {
readonly order?: unknown
readonly path?: unknown
readonly roots?: unknown
readonly start?: unknown
readonly search?: unknown
}) {
return (
query.order !== undefined ||
query.path !== undefined ||
query.roots !== undefined ||
query.start !== undefined ||
query.search !== undefined
)
}
function hasCursorRoutingMismatch(
query: { readonly directory?: string; readonly workspace?: string },
decoded: SessionCursor | undefined,
) {
if (!decoded) return false
if (query.directory !== undefined && query.directory !== decoded.directory) return true
return query.workspace !== undefined && query.workspace !== decoded.workspaceID
}
const sessionCursor = {
encode(
session: SessionV2.Info,
@@ -71,12 +46,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle(
"sessions",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({})
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
catch: () => new HttpApiError.BadRequest({}),
})
if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({})
const order = decoded?.order ?? ctx.query.order ?? "desc"
const filters = decoded ?? {
directory: ctx.query.directory,
@@ -24,8 +24,6 @@ export const WorkspaceRoutingQueryFields = {
workspace: Schema.optional(Schema.String),
}
export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{
@@ -1,6 +1,5 @@
import { OpenApi } from "effect/unstable/httpapi"
import { OpenCodeHttpApi } from "./api"
import { QueryBooleanOpenApi } from "./groups/query"
type OpenApiParameter = {
name: string
@@ -52,31 +51,47 @@ type OpenApiResponse = {
content?: Record<string, { schema?: OpenApiSchema }>
}
// Instance routes use middleware for directory/workspace resolution, but HttpApi
// doesn't surface middleware query params in the spec. Inject them explicitly.
const InstanceQueryParameters = [
{
name: "directory",
in: "query",
required: false,
schema: { type: "string" },
},
{
name: "workspace",
in: "query",
required: false,
schema: { type: "string" },
},
] satisfies OpenApiParameter[]
// Query schemas describe decoded Effect values, but the generated SDK needs the
// public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime.
const QueryParameterSchemas: Record<string, OpenApiSchema> = {
"GET /experimental/session start": { type: "number" },
"GET /experimental/session roots": QueryBooleanOpenApi,
"GET /experimental/session archived": QueryBooleanOpenApi,
const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = {
"GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
"GET /experimental/session cursor": { type: "number" },
"GET /experimental/session limit": { type: "number" },
"GET /session start": { type: "number" },
"GET /session roots": QueryBooleanOpenApi,
"GET /session limit": { type: "number" },
"GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" },
"GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
"GET /api/session limit": { type: "number" },
"GET /api/session start": { type: "number" },
"GET /api/session roots": QueryBooleanOpenApi,
"GET /api/session/{sessionID}/message limit": { type: "number" },
}
} satisfies Record<string, OpenApiSchema>
const LegacyComponentDescriptions: Record<string, string> = {
const PathParameterSchemas = {
sessionID: { type: "string", pattern: "^ses.*" },
messageID: { type: "string", pattern: "^msg.*" },
partID: { type: "string", pattern: "^prt.*" },
permissionID: { type: "string", pattern: "^per.*" },
ptyID: { type: "string", pattern: "^pty.*" },
} satisfies Record<string, OpenApiSchema>
const LegacyComponentDescriptions = {
LogLevel: "Log level",
ServerConfig: "Server configuration for opencode serve and web commands",
LayoutConfig: "@deprecated Always uses stretch layout.",
}
} satisfies Record<string, string>
function matchLegacyOpenApi(input: Record<string, unknown>) {
const spec = input as OpenApiSpec
@@ -107,6 +122,7 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
delete spec.components?.securitySchemes
for (const [path, item] of Object.entries(spec.paths ?? {})) {
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]
if (!operation) continue
@@ -167,8 +183,14 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
},
}
}
const route = `${method.toUpperCase()} ${path}`
for (const param of operation.parameters ?? []) normalizeParameter(param, route)
if (!isInstanceRoute) continue
operation.parameters = [
...InstanceQueryParameters,
...(operation.parameters ?? []).filter(
(param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"),
),
]
for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`)
}
}
return input
@@ -270,7 +292,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
function normalizeComponentDescriptions(spec: OpenApiSpec) {
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
const description = LegacyComponentDescriptions[name]
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
if (description) {
schema.description = description
continue
@@ -416,7 +438,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
}
}
// Simplest fix: generate the raw spec (without transform) to get correct schemas
const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi)
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
const rawSchemas = raw.components?.schemas
if (!rawSchemas) return
for (const name of selfRefs) {
@@ -481,19 +503,38 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] |
function normalizeParameter(param: OpenApiParameter, route: string) {
if (!param.schema || typeof param.schema !== "object") return
if (param.in === "path") {
param.schema = stripOptionalNull(param.schema)
param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
return
}
if (param.in === "query") {
const override = QueryParameterSchemas[`${route} ${param.name}`]
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
if (override) {
param.schema = override
return
}
if (QueryNumberParameters.has(param.name)) {
param.schema = { type: "number" }
return
}
if (QueryBooleanParameters.has(param.name)) {
param.schema = {
anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }],
}
return
}
}
param.schema = stripOptionalNull(param.schema)
}
function pathParameterSchema(route: string, name: string) {
if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas]
if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" }
if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" }
if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" }
return undefined
}
export const PublicApi = OpenCodeHttpApi.annotateMerge(
OpenApi.annotations({
title: "opencode",
@@ -41,7 +41,6 @@ import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { SessionTimeline } from "@/session/timeline"
import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
@@ -211,7 +210,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
SessionSummary.defaultLayer,
SessionTimeline.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
+14 -14
View File
@@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof Reasonin
const filePartSourceBase = {
text: Schema.Struct({
value: Schema.String,
start: Schema.Finite,
end: Schema.Finite,
start: NonNegativeInt,
end: NonNegativeInt,
}).annotate({ identifier: "FilePartSourceText" }),
}
@@ -270,13 +270,13 @@ export const StepFinishPart = Schema.Struct({
snapshot: Schema.optional(Schema.String),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
total: Schema.optional(NonNegativeInt),
input: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
read: NonNegativeInt,
write: NonNegativeInt,
}),
}),
})
@@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({
summary: Schema.optional(Schema.Boolean),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
total: Schema.optional(NonNegativeInt),
input: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
read: NonNegativeInt,
write: NonNegativeInt,
}),
}),
structured: Schema.optional(Schema.Any),
+5 -5
View File
@@ -172,12 +172,12 @@ export const Info = Schema.Struct({
cost: Schema.Finite,
summary: Schema.optional(Schema.Boolean),
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
input: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
read: NonNegativeInt,
write: NonNegativeInt,
}),
}),
}),
+5 -33
View File
@@ -1,4 +1,4 @@
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
@@ -9,7 +9,6 @@ import { Snapshot } from "@/snapshot"
import * as Session from "./session"
import { LLM } from "./llm"
import { MessageV2 } from "./message-v2"
import { Image } from "@/image/image"
import { isOverflow } from "./overflow"
import { PartID } from "./schema"
import type { SessionID } from "./schema"
@@ -93,7 +92,6 @@ export const layer: Layer.Layer<
| LLM.Service
| Permission.Service
| Plugin.Service
| Image.Service
| SessionSummary.Service
| SessionStatus.Service
> = Layer.effect(
@@ -110,7 +108,6 @@ export const layer: Layer.Layer<
const summary = yield* SessionSummary.Service
const scope = yield* Scope.Scope
const status = yield* SessionStatus.Service
const image = yield* Image.Service
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
// Pre-capture snapshot before the LLM stream starts. The AI SDK
@@ -380,41 +377,17 @@ export const layer: Layer.Layer<
case "tool-result": {
const toolCall = yield* readToolCall(value.toolCallId)
const toolAttachments: MessageV2.FilePart[] = (
Array.isArray(value.output.attachments) ? value.output.attachments : []
).filter(
(attachment: unknown): attachment is MessageV2.FilePart =>
isRecord(attachment) &&
attachment.type === "file" &&
typeof attachment.mime === "string" &&
typeof attachment.url === "string",
)
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
attachment.mime.startsWith("image/")
? image.normalize(attachment).pipe(Effect.exit)
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
)
const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
const output = {
...value.output,
output:
omitted === 0
? value.output.output
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
attachments: attachments?.length ? attachments : undefined,
}
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
EventV2.run(SessionEvent.Tool.Success.Sync, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
structured: output.metadata,
structured: value.output.metadata,
content: [
{
type: "text",
text: output.output,
text: value.output.output,
},
...(output.attachments?.map((item: MessageV2.FilePart) => ({
...(value.output.attachments?.map((item: MessageV2.FilePart) => ({
type: "file",
uri: item.url,
mime: item.mime,
@@ -426,7 +399,7 @@ export const layer: Layer.Layer<
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
yield* completeToolCall(value.toolCallId, output)
yield* completeToolCall(value.toolCallId, value.output)
return
}
@@ -785,7 +758,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Plugin.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
),
+29 -34
View File
@@ -4,7 +4,7 @@ import * as EffectZod from "@opencode-ai/core/effect-zod"
import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2"
import * as Log from "@opencode-ai/core/util/log"
import { SessionTimeline } from "./timeline"
import { SessionRevert } from "./revert"
import * as Session from "./session"
import { Agent } from "../agent/agent"
import { Provider } from "@/provider/provider"
@@ -43,7 +43,6 @@ import { Shell } from "@/shell/shell"
import { ShellID } from "@/tool/shell/id"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate"
import { Image } from "@/image/image"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
@@ -81,10 +80,10 @@ const elog = EffectLogger.create({ service: "session.prompt" })
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
readonly loop: (input: LoopInput) => Effect.Effect<MessageV2.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts>
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts, Image.Error>
readonly command: (input: CommandInput) => Effect.Effect<MessageV2.WithParts>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}
@@ -109,12 +108,11 @@ export const layer = Layer.effect(
const lsp = yield* LSP.Service
const registry = yield* ToolRegistry.Service
const truncate = yield* Truncate.Service
const image = yield* Image.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const scope = yield* Scope.Scope
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const timeline = yield* SessionTimeline.Service
const revert = yield* SessionRevert.Service
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
@@ -125,7 +123,7 @@ export const layer = Layer.effect(
return {
cancel: (sessionID: SessionID) => cancel(sessionID),
resolvePromptParts: (template: string) => resolvePromptParts(template),
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
prompt: (input: PromptInput) => prompt(input),
} satisfies TaskPromptOps
})
@@ -747,7 +745,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const { msg, part, cwd } = yield* Effect.gen(function* () {
const ctx = yield* InstanceState.context
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (session.revert) yield* timeline.commitPending({ sessionID: session.id })
if (session.revert) {
yield* revert.cleanup(session)
}
const agent = yield* agents.get(input.agent)
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
@@ -1259,7 +1259,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return [{ ...part, messageID: info.id, sessionID: input.sessionID }]
})
const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe(
Effect.map((x) => x.flat().map(assign)),
)
@@ -1272,11 +1272,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
messageID: input.messageID,
variant: input.variant,
},
{ message: info, parts: resolvedParts },
)
const parts = yield* Effect.forEach(resolvedParts, (part) =>
part.type === "file" && part.mime.startsWith("image/") ? image.normalize(part) : Effect.succeed(part),
{ message: info, parts },
)
const parsed = MessageV2.Info.zod.safeParse(info)
@@ -1372,26 +1368,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return { info, parts }
}, Effect.scoped)
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts, Image.Error> = Effect.fn(
"SessionPrompt.prompt",
)(function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* timeline.commitPending({ sessionID: session.id })
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
if (permissions.length > 0) {
session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
}
const permissions: Permission.Ruleset = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
if (permissions.length > 0) {
session.permission = permissions
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
}
if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })
})
if (input.noReply === true) return message
return yield* loop({ sessionID: input.sessionID })
},
)
const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user")
@@ -1790,9 +1786,8 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SessionTimeline.defaultLayer),
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(
Layer.mergeAll(
Agent.defaultLayer,
+121 -8
View File
@@ -1,10 +1,16 @@
import { Effect, Layer, Context, Schema } from "effect"
import { Bus } from "../bus"
import { Snapshot } from "../snapshot"
import { Storage } from "@/storage/storage"
import { SyncEvent } from "../sync"
import * as Log from "@opencode-ai/core/util/log"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import * as Session from "./session"
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
import { SessionTimeline } from "./timeline"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary"
const log = Log.create({ service: "session.revert" })
@@ -12,7 +18,6 @@ export const RevertInput = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: Schema.optional(PartID),
files: Schema.optional(RewindFilePolicy),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type RevertInput = Schema.Schema.Type<typeof RevertInput>
@@ -27,25 +32,133 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const timeline = yield* SessionTimeline.Service
const sessions = yield* Session.Service
const snap = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const sync = yield* SyncEvent.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
return yield* timeline.rewind(input)
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (rev) {
if (part.type === "patch") patches.push(part)
continue
}
if (!rev) {
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
rev = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
}
}
remaining.push(part)
}
}
}
if (!rev) return session
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
const range = all.filter((msg) => msg.info.id >= rev.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
return yield* timeline.restore(input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
yield* timeline.commitPending({ sessionID: session.id })
if (!session.revert) return
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID })
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) {
yield* sync.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
}
if (session.revert.partID && target) {
const partID = session.revert.partID
const idx = target.parts.findIndex((part) => part.id === partID)
if (idx >= 0) {
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
yield* sync.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
})
}
}
}
yield* sessions.clearRevert(sessionID)
})
return Service.of({ revert, unrevert, cleanup })
}),
)
export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(SessionTimeline.defaultLayer)))
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
),
)
export * as SessionRevert from "./revert"
+4 -7
View File
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod } from "@opencode-ai/core/effect-zod"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)),
@@ -14,7 +14,7 @@ export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
export type SessionID = Schema.Schema.Type<typeof SessionID>
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
@@ -24,7 +24,7 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
export type MessageID = Schema.Schema.Type<typeof MessageID>
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe(
Schema.brand("PartID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
@@ -33,6 +33,3 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
)
export type PartID = Schema.Schema.Type<typeof PartID>
export const RewindFilePolicy = Schema.Union([Schema.Literal("revert"), Schema.Literal("keep")])
export type RewindFilePolicy = Schema.Schema.Type<typeof RewindFilePolicy>
+5 -6
View File
@@ -30,7 +30,7 @@ import { InstanceState } from "@/effect/instance-state"
import { Snapshot } from "@/snapshot"
import { ProjectID } from "../project/schema"
import { WorkspaceID } from "../control-plane/schema"
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
import { SessionID, MessageID, PartID } from "./schema"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -142,9 +142,9 @@ function sessionPath(worktree: string, cwd: string) {
}
const Summary = Schema.Struct({
additions: Schema.Finite,
deletions: Schema.Finite,
files: Schema.Finite,
additions: NonNegativeInt,
deletions: NonNegativeInt,
files: NonNegativeInt,
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
})
@@ -166,7 +166,6 @@ const Time = Schema.Struct({
const Revert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
files: optionalOmitUndefined(RewindFilePolicy),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
@@ -354,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return Math.max(0, value)
return value
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
-159
View File
@@ -1,159 +0,0 @@
import { Effect, Layer, Context, Schema } from "effect"
import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID, RewindFilePolicy } from "./schema"
import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary"
export const RewindInput = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: Schema.optional(PartID),
files: Schema.optional(RewindFilePolicy),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type RewindInput = Schema.Schema.Type<typeof RewindInput>
export interface Interface {
readonly rewind: (input: RewindInput) => Effect.Effect<Session.Info>
readonly restore: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
readonly commitPending: (input: { sessionID: SessionID }) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTimeline") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snap = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const rewind = Effect.fn("SessionTimeline.rewind")(function* (input: RewindInput) {
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
const files = input.files ?? "revert"
let lastUser: MessageV2.User | undefined
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
const range: MessageV2.WithParts[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (rev) {
if (files === "revert" && part.type === "patch") patches.push(part)
continue
}
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
rev = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
...(files === "keep" && { files }),
}
}
remaining.push(part)
}
if (rev) range.push(msg)
}
if (!rev) return session
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
if (files === "revert") {
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot)
}
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const restore = Effect.fn("SessionTimeline.restore")(function* (input: { sessionID: SessionID }) {
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return session
if (session.revert.files !== "keep" && session.revert.snapshot) yield* snap.restore(session.revert.snapshot)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID).pipe(Effect.orDie)
})
const commitPending = Effect.fn("SessionTimeline.commitPending")(function* (input: { sessionID: SessionID }) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID })
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) {
yield* sessions.removeMessage({
sessionID,
messageID: msg.info.id,
})
}
if (session.revert.partID && target) {
const idx = target.parts.findIndex((part) => part.id === session.revert?.partID)
if (idx >= 0) {
for (const part of target.parts.slice(idx)) {
yield* sessions.removePart({
sessionID,
messageID: target.info.id,
partID: part.id,
})
}
}
}
yield* sessions.clearRevert(sessionID)
})
return Service.of({ rewind, restore, commitPending })
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
)
export * as SessionTimeline from "./timeline"

Some files were not shown because too many files have changed in this diff Show More