Compare commits

..
Author SHA1 Message Date
Dax Raad 1aa2777ca4 Fix TUI session switch flicker 2026-05-10 00:14:53 -04:00
Kit LangtonandGitHub fb4bab8a66 Remove redundant ID Zod overrides (#26633) 2026-05-09 23:12:21 -04:00
opencode-agent[bot] b3526f6ce9 chore: generate 2026-05-10 03:03:37 +00:00
Kit LangtonandGitHub f220f02a2f Source workspace path pattern (#26632) 2026-05-09 23:02:31 -04:00
opencode-agent[bot] 235a86fb60 chore: generate 2026-05-10 02:59:46 +00:00
Kit LangtonandGitHub 67b9c9c027 Source HTTP API ID path patterns (#26623) 2026-05-09 22:58:47 -04:00
opencode 2f11c9f7ed sync release versions for v1.14.46 2026-05-10 02:34:36 +00:00
opencode-agent[bot] e1c1193f3e chore: generate 2026-05-10 02:11:45 +00:00
Kit LangtonandGitHub 29250a0efb fix(session): loosen remaining stored numeric schemas to tolerate legacy data (#26622) 2026-05-09 22:10:48 -04:00
Kit LangtonandGitHub c6e6bdf59f fix(session): tolerate negative token counts in stored parts (#26620) 2026-05-09 22:10:44 -04:00
opencode-agent[bot] d80e1199ca chore: generate 2026-05-10 02:06:39 +00:00
Kit LangtonandGitHub 10ea59066f feat(skill): built-in opencode-meta skill (#26617) 2026-05-09 22:05:37 -04:00
Kit LangtonandGitHub 79d6b10d7c fix(mcp): tolerate output schema ref failures (#26614) 2026-05-09 22:03:59 -04:00
Kit LangtonandGitHub 6e78f36a0f Narrow HTTP API numeric query overrides (#26618) 2026-05-09 22:02:51 -04:00
Kit LangtonandGitHub 16866e1180 Share HTTP API boolean query schema (#26615) 2026-05-09 21:41:15 -04:00
opencode-agent[bot] 6d130e5deb chore: generate 2026-05-10 01:13:41 +00:00
Kit LangtonandGitHub e30d8173c1 Fix OpenAPI workspace query drift (#26609) 2026-05-09 21:12:34 -04:00
opencode 7a79f3a5ea sync release versions for v1.14.45 2026-05-10 00:07:24 +00:00
b8ca71d309 fix(task): subagent inherits parent agent's deny rules (Plan Mode security bypass) (#26597)
Co-authored-by: Developer <temp@example.com>
2026-05-09 23:51:55 +00:00
6849f96825 refactor(provider): share model status schema (#26595)
Co-authored-by: Developer <temp@example.com>
2026-05-09 19:20:31 -04:00
00c3248295 fix(config): allow active provider model status (#26592)
Co-authored-by: Developer <temp@example.com>
2026-05-09 18:59:05 -04:00
103 changed files with 3410 additions and 1998 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -229,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -283,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -313,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -342,7 +342,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -360,7 +360,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.44",
"version": "1.14.46",
"bin": {
"opencode": "./bin/opencode",
},
@@ -495,7 +495,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -533,7 +533,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -548,7 +548,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -583,7 +583,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -632,7 +632,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.44",
"version": "1.14.46",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.44",
"version": "1.14.46",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.44",
"version": "1.14.46",
"$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.44",
"version": "1.14.46",
"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.44",
"version": "1.14.46",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+11
View File
@@ -1,4 +1,5 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -10,6 +11,13 @@ 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
@@ -48,6 +56,9 @@ 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.44",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.44",
"version": "1.14.46",
"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.44"
version = "1.14.46"
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.44/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/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.44/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/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.44/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/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.44/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.44",
"version": "1.14.46",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44",
"version": "1.14.46",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44",
"version": "1.14.46",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.44",
"version": "1.14.46",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -0,0 +1,204 @@
# 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`.
@@ -0,0 +1,33 @@
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 }]),
]
}
@@ -528,21 +528,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const infos: (typeof draft.message)[string] = []
for (const message of messages.data ?? []) {
infos.push(message.info)
draft.part[message.info.id] = message.parts
}
draft.message[sessionID] = infos
draft.session_diff[sessionID] = diff.data ?? []
}),
)
const sessionMessages = messages.data ?? []
batch(() => {
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
}),
)
setStore("todo", sessionID, todo.data ?? [])
setStore("message", sessionID, reconcile(sessionMessages.map((message) => message.info), { key: "id" }))
sessionMessages.forEach((message) => {
setStore("part", message.info.id, reconcile(message.parts, { key: "id" }))
})
setStore("session_diff", sessionID, diff.data ?? [])
})
fullSyncedSessions.add(sessionID)
},
},
+2 -1
View File
@@ -1,6 +1,7 @@
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),
@@ -49,7 +50,7 @@ export const Model = Schema.Struct({
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
status: Schema.optional(ModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
@@ -1,12 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
Schema.brand("WorkspaceID"),
)
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
export type WorkspaceID = typeof workspaceIdSchema.Type
-8
View File
@@ -1,4 +1,3 @@
import z from "zod"
import { randomBytes } from "crypto"
const prefixes = {
@@ -7,19 +6,12 @@ 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
+4
View File
@@ -0,0 +1,4 @@
declare module "*.md" {
const content: string
export default content
}
+23 -25
View File
@@ -6,6 +6,7 @@ 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"
@@ -36,16 +37,7 @@ import { withStatics } from "@opencode-ai/core/schema"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
const TolerantToolSchema = z.looseObject({
name: z.string(),
description: z.string().optional(),
inputSchema: z
.object({
type: z.literal("object"),
properties: z.record(z.string(), z.unknown()).optional(),
required: z.array(z.string()).optional(),
})
.catchall(z.unknown()),
const TolerantToolSchema = ToolSchema.extend({
outputSchema: z.unknown().optional(),
})
@@ -137,28 +129,34 @@ function remoteURL(key: string, value: string) {
log.warn("invalid remote mcp url", { key })
}
function isSchemaReferenceError(err: unknown) {
return err instanceof Error && /can't resolve reference|schema.*reference|reference.*schema/i.test(err.message)
function isOutputSchemaValidationError(error: Error) {
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
error.message,
)
}
const wrapAsError = (err: unknown) => (err instanceof Error ? err : new Error(String(err)))
function listToolsTolerant(key: string, client: MCPClient, timeout: number) {
function listTools(key: string, client: MCPClient, timeout: number) {
return Effect.tryPromise({
try: () => client.listTools(undefined, { timeout }),
catch: wrapAsError,
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
}).pipe(
Effect.map((result) => result.tools),
Effect.catch((err) => {
if (!isSchemaReferenceError(err)) return Effect.fail(err)
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", {
key,
error: err,
})
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: wrapAsError,
}).pipe(Effect.map((result) => result.tools as MCPToolDef[]))
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,
})),
),
)
}),
)
}
@@ -195,7 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
}
function defs(key: string, client: MCPClient, timeout?: number) {
return listToolsTolerant(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
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, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } 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")).annotate({ [ZodOverride]: Identifier.schema("permission") }),
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
+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: NonNegativeInt,
deletions: NonNegativeInt,
additions: Schema.Finite,
deletions: Schema.Finite,
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: NonNegativeInt,
deletions: NonNegativeInt,
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.Literals(["added", "deleted", "modified"]),
})
.annotate({ identifier: "VcsFileStatus" })
@@ -0,0 +1,9 @@
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"
+2 -1
View File
@@ -8,6 +8,7 @@ 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,
@@ -71,7 +72,7 @@ export const Model = Schema.Struct({
),
}),
),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
status: Schema.optional(CatalogModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
+2 -1
View File
@@ -28,6 +28,7 @@ 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" })
@@ -897,7 +898,7 @@ export const Model = Schema.Struct({
capabilities: ProviderCapabilities,
cost: ProviderCost,
limit: ProviderLimit,
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
status: ModelStatus,
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, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID"))
export type PtyID = typeof ptyIdSchema.Type
+2 -5
View File
@@ -1,13 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } 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")).annotate({ [ZodOverride]: Identifier.schema("question") }),
) {
export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) {
static ascending(id?: string): QuestionID {
return this.make(Identifier.ascending("question", id))
}
@@ -5,6 +5,7 @@ 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" })
@@ -16,6 +17,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/config"
@@ -13,6 +13,7 @@ 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({
@@ -22,6 +23,7 @@ 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,
@@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config")
}),
),
HttpApiEndpoint.get("providers", `${root}/providers`, {
query: WorkspaceRoutingQuery,
success: described(Provider.ConfigProvidersResult, "List of providers"),
}).annotateMerge(
OpenApi.annotations({
@@ -4,12 +4,17 @@ import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Schema, SchemaGetter } from "effect"
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, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@@ -48,12 +53,6 @@ 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,
@@ -82,6 +81,7 @@ 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,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, {
query: WorkspaceRoutingQuery,
success: described(ConsoleOrgList, "Switchable Console orgs"),
error: HttpApiError.InternalServerError,
}).annotateMerge(
@@ -102,6 +103,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, {
query: WorkspaceRoutingQuery,
payload: ConsoleSwitchPayload,
success: described(Schema.Boolean, "Switch success"),
error: HttpApiError.BadRequest,
@@ -125,6 +127,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, {
query: WorkspaceRoutingQuery,
success: described(ToolIDs, "Tool IDs"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -136,6 +139,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
),
HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, {
query: WorkspaceRoutingQuery,
success: described(WorktreeList, "List of worktree directories"),
}).annotateMerge(
OpenApi.annotations({
@@ -145,6 +149,7 @@ 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,
@@ -156,6 +161,7 @@ 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,
@@ -167,6 +173,7 @@ 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,
@@ -189,6 +196,7 @@ 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,7 +5,11 @@ 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, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
export const FileQuery = Schema.Struct({
@@ -97,6 +101,7 @@ 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,7 +8,11 @@ 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, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
const PathInfo = Schema.Struct({
@@ -55,6 +59,7 @@ 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({
@@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("path", InstancePaths.path, {
query: WorkspaceRoutingQuery,
success: PathInfo,
}).annotateMerge(
OpenApi.annotations({
@@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
query: WorkspaceRoutingQuery,
success: described(Vcs.Info, "VCS info"),
}).annotateMerge(
OpenApi.annotations({
@@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
),
HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Vcs.FileStatus), "VCS status"),
}).annotateMerge(
OpenApi.annotations({
@@ -103,6 +111,7 @@ 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",
@@ -115,6 +124,7 @@ 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,
@@ -126,6 +136,7 @@ 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({
@@ -135,6 +146,7 @@ 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({
@@ -144,6 +156,7 @@ 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({
@@ -153,6 +166,7 @@ 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({
@@ -162,6 +176,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
export const AddPayload = Schema.Struct({
@@ -42,6 +42,7 @@ 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({
@@ -51,6 +52,7 @@ 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,
@@ -63,6 +65,7 @@ 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(
@@ -74,6 +77,7 @@ 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],
@@ -87,6 +91,7 @@ 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(
@@ -98,6 +103,7 @@ 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(
@@ -109,6 +115,7 @@ 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({
@@ -118,6 +125,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/permission"
@@ -18,6 +18,7 @@ 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({
@@ -28,6 +29,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/project"
@@ -19,6 +19,7 @@ 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({
@@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.get("current", `${root}/current`, {
query: WorkspaceRoutingQuery,
success: described(Project.Info, "Current project information"),
}).annotateMerge(
OpenApi.annotations({
@@ -37,6 +39,7 @@ 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({
@@ -47,6 +50,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/provider"
@@ -15,6 +15,7 @@ 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({
@@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
query: WorkspaceRoutingQuery,
success: described(ProviderAuth.Methods, "Provider auth methods"),
}).annotateMerge(
OpenApi.annotations({
@@ -34,6 +36,7 @@ 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,
@@ -46,6 +49,7 @@ 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,7 +5,11 @@ 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, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@@ -37,6 +41,7 @@ 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({
@@ -46,6 +51,7 @@ 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({
@@ -55,6 +61,7 @@ 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,
@@ -67,6 +74,7 @@ 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(
@@ -78,6 +86,7 @@ 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],
@@ -90,6 +99,7 @@ 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(
@@ -101,6 +111,7 @@ 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(
@@ -129,6 +140,7 @@ 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(
@@ -0,0 +1,8 @@
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")),
}),
)
@@ -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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/question"
@@ -19,6 +19,7 @@ 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({
@@ -29,6 +30,7 @@ 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],
@@ -41,6 +43,7 @@ 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,21 +10,20 @@ import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { Schema, SchemaGetter, Struct } from "effect"
import { Schema, 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, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
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"])),
@@ -116,6 +115,7 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
query: WorkspaceRoutingQuery,
success: described(StatusMap, "Get session status"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -127,6 +127,7 @@ 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(
@@ -138,6 +139,7 @@ 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(
@@ -149,6 +151,7 @@ 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(
@@ -183,6 +186,7 @@ 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(
@@ -193,6 +197,7 @@ 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,
@@ -205,6 +210,7 @@ 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(
@@ -216,6 +222,7 @@ 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],
@@ -228,6 +235,7 @@ 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,
@@ -240,6 +248,7 @@ 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(
@@ -251,6 +260,7 @@ 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],
@@ -264,6 +274,7 @@ 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(
@@ -275,6 +286,7 @@ 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(
@@ -286,6 +298,7 @@ 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],
@@ -298,6 +311,7 @@ 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],
@@ -310,6 +324,7 @@ 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],
@@ -323,6 +338,7 @@ 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],
@@ -335,6 +351,7 @@ 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],
@@ -347,6 +364,7 @@ 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],
@@ -360,6 +378,7 @@ 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(
@@ -371,6 +390,7 @@ 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],
@@ -384,6 +404,7 @@ 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(
@@ -396,6 +417,7 @@ 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(
@@ -406,6 +428,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/sync"
@@ -46,6 +46,7 @@ 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({
@@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync")
}),
),
HttpApiEndpoint.post("replay", SyncPaths.replay, {
query: WorkspaceRoutingQuery,
payload: ReplayPayload,
success: described(ReplayResponse, "Replayed sync events"),
error: HttpApiError.BadRequest,
@@ -66,6 +68,7 @@ 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,
@@ -77,6 +80,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { ApiNotFoundError } from "../errors"
import { described } from "./metadata"
@@ -54,6 +54,7 @@ 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,
@@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Help dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Session dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Theme dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("openModels", TuiPaths.openModels, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Model dialog opened successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt submitted successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Prompt cleared successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -119,6 +126,7 @@ 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,
@@ -130,6 +138,7 @@ 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(
@@ -140,6 +149,7 @@ 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,
@@ -151,6 +161,7 @@ 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],
@@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui")
}),
),
HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, {
query: WorkspaceRoutingQuery,
success: described(TuiRequestPayload, "Next TUI request"),
}).annotateMerge(
OpenApi.annotations({
@@ -171,6 +183,7 @@ 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,46 +3,31 @@ 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: 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" }),
query: MessagesQuery,
success: Schema.Struct({
items: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({
@@ -1,67 +1,39 @@
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, SchemaGetter } from "effect"
import { Schema } 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: 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" }),
query: SessionsQuery,
success: Schema.Struct({
items: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({
@@ -82,6 +54,7 @@ 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),
@@ -98,6 +71,7 @@ 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({
@@ -110,6 +84,7 @@ 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({
@@ -122,6 +97,7 @@ 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 } from "../middleware/workspace-routing"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/workspace"
@@ -40,6 +40,7 @@ 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({
@@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.Info), "Workspaces"),
}).annotateMerge(
OpenApi.annotations({
@@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("create", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
payload: CreatePayload,
success: described(Workspace.Info, "Workspace created"),
error: HttpApiError.BadRequest,
@@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, {
query: WorkspaceRoutingQuery,
success: described(HttpApiSchema.NoContent, "Workspace list synced"),
}).annotateMerge(
OpenApi.annotations({
@@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.get("status", WorkspacePaths.status, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"),
}).annotateMerge(
OpenApi.annotations({
@@ -88,6 +93,7 @@ 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(
@@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
HttpApiEndpoint.post("warp", WorkspacePaths.warp, {
query: WorkspaceRoutingQuery,
payload: WarpPayload,
success: described(HttpApiSchema.NoContent, "Session warped"),
error: [ApiWorkspaceWarpError, ApiVcsApplyError],
@@ -34,6 +34,7 @@ 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,6 +22,31 @@ 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,
@@ -46,10 +71,12 @@ 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,6 +24,8 @@ export const WorkspaceRoutingQueryFields = {
workspace: Schema.optional(Schema.String),
}
export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{
@@ -51,47 +51,29 @@ 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 QueryNumberParameters = new Set(["start", "cursor", "limit", "method"])
const QueryBooleanParameters = new Set(["roots", "archived"])
const QueryParameterSchemas = {
const QueryParameterSchemas: Record<string, OpenApiSchema> = {
"GET /experimental/session start": { type: "number" },
"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 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 },
} satisfies Record<string, OpenApiSchema>
"GET /api/session limit": { type: "number" },
"GET /api/session start": { type: "number" },
"GET /api/session/{sessionID}/message limit": { type: "number" },
}
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 = {
const LegacyComponentDescriptions: Record<string, string> = {
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
@@ -122,7 +104,6 @@ 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
@@ -183,14 +164,8 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
},
}
}
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}`)
const route = `${method.toUpperCase()} ${path}`
for (const param of operation.parameters ?? []) normalizeParameter(param, route)
}
}
return input
@@ -292,7 +267,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
function normalizeComponentDescriptions(spec: OpenApiSpec) {
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions]
const description = LegacyComponentDescriptions[name]
if (description) {
schema.description = description
continue
@@ -438,7 +413,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) {
}
}
// Simplest fix: generate the raw spec (without transform) to get correct schemas
const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec
const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi)
const rawSchemas = raw.components?.schemas
if (!rawSchemas) return
for (const name of selfRefs) {
@@ -503,19 +478,15 @@ 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 = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema)
param.schema = stripOptionalNull(param.schema)
return
}
if (param.in === "query") {
const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas]
const override = QueryParameterSchemas[`${route} ${param.name}`]
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"] }],
@@ -526,15 +497,6 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
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",
+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: NonNegativeInt,
end: NonNegativeInt,
start: Schema.Finite,
end: Schema.Finite,
}).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(NonNegativeInt),
input: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: NonNegativeInt,
write: NonNegativeInt,
read: Schema.Finite,
write: Schema.Finite,
}),
}),
})
@@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({
summary: Schema.optional(Schema.Boolean),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(NonNegativeInt),
input: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: NonNegativeInt,
write: NonNegativeInt,
read: Schema.Finite,
write: Schema.Finite,
}),
}),
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: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: NonNegativeInt,
write: NonNegativeInt,
read: Schema.Finite,
write: Schema.Finite,
}),
}),
}),
+4 -4
View File
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe(
export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)),
@@ -14,7 +14,7 @@ export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.sche
export type SessionID = Schema.Schema.Type<typeof SessionID>
export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe(
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
Schema.brand("MessageID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)),
@@ -24,7 +24,7 @@ export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.sche
export type MessageID = Schema.Schema.Type<typeof MessageID>
export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe(
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
Schema.brand("PartID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
+4 -4
View File
@@ -142,9 +142,9 @@ function sessionPath(worktree: string, cwd: string) {
}
const Summary = Schema.Struct({
additions: NonNegativeInt,
deletions: NonNegativeInt,
files: NonNegativeInt,
additions: Schema.Finite,
deletions: Schema.Finite,
files: Schema.Finite,
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
})
@@ -353,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 value
return Math.max(0, value)
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
+20
View File
@@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown"
import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery"
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude"
@@ -25,6 +26,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
// Built-in skill that ships with opencode. The model's intuition for what an
// opencode.json should look like is often wrong, and opencode hard-fails on
// invalid config, so users hit cryptic startup errors. Loading this skill
// when the model is asked to touch opencode's own config files gives it the
// actual schemas instead of guesses.
const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode"
const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION =
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
@@ -230,6 +240,16 @@ export const layer = Layer.effect(
const state = yield* InstanceState.make(
Effect.fn("Skill.state")(function* () {
const s: State = { skills: {}, dirs: new Set() }
// Register the built-in skill BEFORE disk discovery so a user-disk
// skill with the same name can override it.
if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) {
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
location: "<built-in>",
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
}
}
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
return s
}),
@@ -0,0 +1,354 @@
<!--
Built-in skill. Name and description are registered in code at
packages/opencode/src/skill/index.ts (see SKILL_NAME and SKILL_DESCRIPTION).
The body below becomes the skill's content.
-->
# Customizing opencode
opencode validates its own config strictly and refuses to start when a field
is wrong. The shapes below are the accepted shapes. When in doubt, fetch
`https://opencode.ai/config.json` (the JSON Schema) and validate against it.
Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"`
so the user's editor catches mistakes as they type.
## Where files live
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
Configs from each scope are deep-merged. Project overrides global. Unknown
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
## opencode.json
Every field is optional.
```json
{
"$schema": "https://opencode.ai/config.json",
"username": "string",
"model": "provider/model-id",
"small_model": "provider/model-id",
"default_agent": "agent-name",
"shell": "/bin/zsh",
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
"share": "manual" | "auto" | "disabled",
"autoupdate": true | false | "notify",
"snapshot": true,
"instructions": ["AGENTS.md", "docs/style.md"],
"skills": {
"paths": [".opencode/skills", "/abs/path/to/skills"],
"urls": ["https://example.com/.well-known/skills/"]
},
"agent": {
"my-agent": {
"model": "anthropic/claude-sonnet-4-6",
"mode": "subagent",
"description": "...",
"permission": { "edit": "deny" }
}
},
"command": {
"deploy": { "description": "...", "prompt": "..." }
},
"provider": {
"anthropic": { "options": { "apiKey": "..." } }
},
"disabled_providers": ["openai"],
"enabled_providers": ["anthropic"],
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": {}
},
"remote-thing": {
"type": "remote",
"url": "https://...",
"headers": { "Authorization": "Bearer ..." }
}
},
"plugin": [
"opencode-gemini-auth",
"opencode-foo@1.2.3",
"./local-plugin.ts",
["opencode-bar", { "option": "value" }]
],
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "*": "ask" }
},
"formatter": false,
"lsp": false,
"experimental": {
"primary_tools": ["edit"],
"mcp_timeout": 30000
},
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
"compaction": { "auto": true, "tail_turns": 15 }
}
```
Shape notes worth being explicit about:
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
- `skills` is an object with `paths` and/or `urls`, not an array.
- `agent` is an object keyed by agent name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name.
## Skills
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
file is named `SKILL.md` exactly, and lives in its own folder named after the
skill:
```
.opencode/skills/my-skill/SKILL.md
```
Frontmatter:
```markdown
---
name: my-skill
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
---
# My Skill
(skill body in markdown: instructions, examples, references)
```
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
- Optional: `license`, `compatibility`, `metadata` (string-string map).
Register skills from non-default locations via `skills.paths` (scanned
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
skills).
## Agents
Two ways to define an agent. Use the file form for anything non-trivial.
### Inline (in `opencode.json`)
```json
{
"agent": {
"my-reviewer": {
"description": "Reviews PRs for style violations.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-6",
"permission": { "edit": "deny", "bash": "ask" },
"prompt": "You are a strict PR reviewer..."
}
}
}
```
### File
```
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
```
```markdown
---
description: Reviews PRs for style violations.
mode: subagent
model: anthropic/claude-sonnet-4-6
permission:
edit: deny
bash: ask
---
You are a strict PR reviewer. Focus on...
```
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
frontmatter.
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
unknown field is silently routed into `options`.
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
file, `disable: true` in frontmatter.
`default_agent` must point to a non-hidden, primary-mode agent.
### Built-in agents
opencode ships with `build`, `plan`, `general`, `explore`, plus optionally
`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents:
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { <name>: { ... } }`.
## Plugins
`plugin:` is an array. Each entry is one of:
```json
"plugin": [
"opencode-gemini-auth", // npm spec, latest
"opencode-foo@1.2.3", // npm spec, pinned
"./local-plugin.ts", // file path, relative to the declaring config
"file:///abs/path/plugin.js", // file URL
["opencode-bar", { "key": "val" }] // tuple form with options
]
```
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
`.opencode/plugin/` or `.opencode/plugins/`.
A plugin module exports `default` (or any named export) of type
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
function, not a plain object literal, and the function returns an object
(return `{}` if there is nothing to register).
```ts
import type { Plugin } from "@opencode-ai/plugin"
export default (async ({ client, project, directory, $ }) => {
return {
config: (cfg) => {
// cfg is the live merged config; mutate fields here.
},
"tool.execute.before": async (input, output) => {
// mutate output.args before the tool runs
},
}
}) satisfies Plugin
```
Hook surface (mutate `output` in place; return `void`):
- `event(input)`: every bus event
- `config(cfg)`: once on init with the merged config
- `chat.message`, `chat.params`, `chat.headers`
- `tool.execute.before`, `tool.execute.after`
- `tool.definition`
- `command.execute.before`
- `shell.env`
- `permission.ask`
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
`experimental.text.complete`
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
`auth: { ... }`, `provider: { ... }`.
## MCP servers
`mcp:` is an object keyed by server name. Each server is discriminated by
`type`:
```json
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": { "BROWSER": "chromium" }
},
"github": {
"type": "remote",
"url": "https://...",
"enabled": true,
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
},
"old-server": { "enabled": false }
}
}
```
`command` is an array of strings. `type` is required. Use `enabled: false` to
disable a server inherited from a parent config.
## Permissions
```json
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
}
```
Actions: `"allow"`, `"ask"`, `"deny"`.
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
object `{ pattern: action }`. Within an object, **insertion order matters**.
opencode evaluates the LAST matching rule, so put broad rules first and narrow
rules last.
`permission: "allow"` (a string at the top level) is shorthand for "allow
everything" and is rarely what the user wants.
Known permission keys: `read, edit, glob, grep, list, bash, task,
external_directory, todowrite, question, webfetch, websearch, codesearch,
repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite,
question, webfetch, websearch, codesearch, doom_loop`) only accept a flat
action, not a per-pattern object.
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
or globs like `~/projects/**`).
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
the `plan` agent's permission ruleset (`edit: deny *`).
## Escape hatches
When a user's config is broken and opencode won't start, these env vars help:
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
and start from globals only. Run from the project directory, opencode loads,
the user edits the broken file, then they restart without the flag.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
inject inline JSON as a final local-scope merge.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `OPENCODE_PURE=1`: skip external plugins entirely.
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
`~/.claude/` and `~/.agents/`.
## When proposing edits
- Validate against the schema before writing. If you are unsure of a field's
exact shape, fetch `https://opencode.ai/config.json` rather than guessing.
- Preserve `$schema` and any existing fields the user did not ask to change.
- For agent, skill, and plugin definitions, prefer creating new files in the
correct location over inlining everything in `opencode.json`.
- If the user's existing config is malformed, point them at the env-var escape
hatch above so they can edit from inside opencode without breaking their
session.
- opencode hard-fails on invalid config by design. There is no graceful
degradation, so get the shape right the first time.
+2 -2
View File
@@ -25,8 +25,8 @@ export const FileDiff = Schema.Struct({
// session response and broke session loading on Desktop.
file: Schema.optional(Schema.String),
patch: Schema.optional(Schema.String),
additions: NonNegativeInt,
deletions: NonNegativeInt,
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
})
.annotate({ identifier: "SnapshotFileDiff" })
+2 -2
View File
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe(
export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe(
Schema.brand("EventID"),
withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
+2 -2
View File
@@ -1,10 +1,10 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics } from "@opencode-ai/core/schema"
const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID"))
const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID"))
export type ToolID = typeof toolIdSchema.Type
+11 -26
View File
@@ -4,6 +4,7 @@ import { Session } from "@/session/session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "@/config/config"
import { Effect, Exit, Schema } from "effect"
@@ -58,41 +59,25 @@ export const TaskTool = Tool.define(
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const canTask = next.permission.some((rule) => rule.permission === id)
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const taskID = params.task_id
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const parentAgent = parent.agent
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(parent.permission ?? []).filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: id,
pattern: "*" as const,
action: "deny" as const,
},
]),
...deriveSubagentSessionPermission({
parentSessionPermission: parent.permission ?? [],
parentAgent,
subagent: next,
}),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
@@ -144,8 +129,8 @@ export const TaskTool = Tool.define(
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }),
...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
+2 -1
View File
@@ -1,4 +1,5 @@
import { withStatics } from "@opencode-ai/core/schema"
import { ModelStatus } from "@/provider/model-status"
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
@@ -114,7 +115,7 @@ export class Info extends Schema.Class<Info>("Model.Info")({
released: DateTimeUtcFromMillis,
}),
cost: Cost.pipe(Schema.Array),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
status: ModelStatus,
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
+7 -7
View File
@@ -118,12 +118,12 @@ export namespace Step {
finish: Schema.String,
cost: Schema.Finite,
tokens: Schema.Struct({
input: NonNegativeInt,
output: NonNegativeInt,
reasoning: NonNegativeInt,
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: NonNegativeInt,
write: NonNegativeInt,
read: Schema.Finite,
write: Schema.Finite,
}),
}),
snapshot: Schema.String.pipe(Schema.optional),
@@ -305,7 +305,7 @@ export namespace Tool {
export const RetryError = Schema.Struct({
message: Schema.String,
statusCode: NonNegativeInt.pipe(Schema.optional),
statusCode: Schema.Finite.pipe(Schema.optional),
isRetryable: Schema.Boolean,
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
responseBody: Schema.String.pipe(Schema.optional),
@@ -320,7 +320,7 @@ export const Retried = EventV2.define({
aggregate: "sessionID",
schema: {
...Base,
attempt: NonNegativeInt,
attempt: Schema.Finite,
error: RetryError,
},
})
@@ -0,0 +1,141 @@
/**
* Reproducer for opencode issue #26514:
*
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
* However, when the plan agent spawns a subagent via the `task` tool, the
* subagent retains full file modification capabilities a security bypass.
*
* This test replicates the permission ruleset that would govern a
* `general` subagent when launched from a `plan` parent session, mirroring
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
* subagent agent permissions, evaluated as in `session/prompt.ts`).
*
* The expected (secure) behavior is that the subagent inherits the plan
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
* origin/dev this assertion fails because the parent **agent** permissions
* are not propagated to the subagent only the parent **session**
* permissions are passed through, and Plan Mode's restrictions live on the
* agent, not the session.
*/
import { test, expect, afterEach } from "bun:test"
import { Effect } from "effect"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { WithInstance } from "../../src/project/with-instance"
import { Agent } from "../../src/agent/agent"
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
import { Permission } from "../../src/permission"
afterEach(async () => {
await disposeAllInstances()
})
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
}
// `deriveSubagentSessionPermission` is imported from production. The test
// exercises the actual helper that task.ts uses to build the subagent's
// session permission, so any regression in that helper trips this test.
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
expect(planAgent).toBeDefined()
expect(generalAgent).toBeDefined()
// Sanity: the plan agent itself blocks edit. (Note: `write` and
// `apply_patch` route through the `edit` permission at the runtime
// tool layer — see Permission.disabled / EDIT_TOOLS.)
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
// Simulate the plan-mode parent session: in real flow the plan
// session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: generalAgent!,
})
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
},
})
})
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
// Sibling check: even though `explore` is intrinsically read-only, the
// bug surface is the same. Including this case to document that the fix
// should propagate the parent **agent** permissions, not just deny edit
// when the subagent happens to already deny it.
await using tmp = await tmpdir()
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const explore = await load(tmp.path, (svc) => svc.get("explore"))
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: explore!,
})
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
// Already deny — sanity check.
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
},
})
})
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
// The most damaging case: a user-defined subagent with default
// permissions (allow-by-default, like `general`). The subagent must NOT
// be able to edit when the parent agent is `plan`.
await using tmp = await tmpdir({
config: {
agent: {
my_subagent: {
description: "A user-defined subagent",
mode: "subagent",
},
},
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
subagent: my!,
})
const effective = Permission.merge(my!.permission, subagentSessionPermission)
// BUG: on origin/dev edit resolves to "allow" because the plan
// agent's `edit: deny *` rule never reaches the subagent.
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
},
})
})
@@ -7,8 +7,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema"
function createTextPart(text: string): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "text" as const,
text,
}
@@ -17,8 +17,8 @@ function createTextPart(text: string): MessageV2.Part {
function createReasoningPart(text: string): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "reasoning" as const,
text,
time: { start: 0 },
@@ -29,8 +29,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
if (status === "completed") {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "tool" as const,
callID: "c1",
tool,
@@ -46,8 +46,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
}
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "tool" as const,
callID: "c1",
tool,
@@ -62,8 +62,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn
function createStepStartPart(): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "step-start" as const,
}
}
@@ -71,8 +71,8 @@ function createStepStartPart(): MessageV2.Part {
function createStepFinishPart(): MessageV2.Part {
return {
id: PartID.ascending(),
sessionID: SessionID.make("s"),
messageID: MessageID.make("m"),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
type: "step-finish" as const,
reason: "done",
cost: 0,
+67 -1
View File
@@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index"
// Per-client state for controlling mock behavior
interface MockClientState {
tools: Array<{ name: string; description?: string; inputSchema: object }>
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
listToolsCalls: number
requestCalls: number
listToolsShouldFail: boolean
listToolsError: string
listPromptsShouldFail: boolean
@@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState {
state = {
tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }],
listToolsCalls: 0,
requestCalls: 0,
listToolsShouldFail: false,
listToolsError: "listTools failed",
listPromptsShouldFail: false,
@@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
return { tools: this._state?.tools ?? [] }
}
async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) {
if (this._state) this._state.requestCalls++
if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] })
throw new Error(`unsupported request: ${request.method}`)
}
async listPrompts() {
if (this._state?.listPromptsShouldFail) {
throw new Error("listPrompts failed")
@@ -205,6 +213,11 @@ function withInstance(
}
}
function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
if ("status" in status) return status.status
return status[server]?.status
}
// ========================================================================
// Test: tools() are cached after connect
// ========================================================================
@@ -433,6 +446,59 @@ test(
),
)
test(
"falls back when MCP output schema refs fail SDK tool discovery",
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "stitch-like-server"
const serverState = getOrCreateClientState("stitch-like-server")
serverState.listToolsShouldFail = true
serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
serverState.tools = [
{
name: "render_screen",
description: "renders a screen",
inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] },
outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } },
},
]
const addResult = yield* mcp.add("stitch-like-server", {
type: "local",
command: ["echo", "test"],
})
expect(statusName(addResult.status, "stitch-like-server")).toBe("connected")
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true)
expect(serverState.listToolsCalls).toBe(1)
expect(serverState.requestCalls).toBe(1)
}),
),
)
test(
"does not fall back for non-schema MCP tool discovery errors",
withInstance({}, (mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "broken-server"
const serverState = getOrCreateClientState("broken-server")
serverState.listToolsShouldFail = true
serverState.listToolsError = "transport closed"
const addResult = yield* mcp.add("broken-server", {
type: "local",
command: ["echo", "test"],
})
expect(statusName(addResult.status, "broken-server")).toBe("failed")
expect(serverState.listToolsCalls).toBe(1)
expect(serverState.requestCalls).toBe(0)
}),
),
)
// ========================================================================
// Test: disabled server via config
// ========================================================================
@@ -1,241 +0,0 @@
// Reproducer for opencode issue #26529
//
// When an MCP server's `tools/list` response contains a tool whose
// `outputSchema` has an unresolved `$ref` (e.g. `#/$defs/ScreenInstance`),
// the MCP SDK's response validation throws on the entire `listTools()`
// call. opencode currently treats this as a fatal error and marks the
// whole server as `failed`, even though the server has other valid tools
// that should still be usable.
//
// Expected behavior: opencode should skip tools with malformed schemas
// and keep the server connected with its remaining valid tools.
import { test, expect, mock, beforeEach } from "bun:test"
import { InstanceRuntime } from "../../src/project/instance-runtime"
import { Effect } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
// --- Mock infrastructure (mirrors lifecycle.test.ts patterns) ---
interface MockClientState {
tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }>
listToolsShouldFail: boolean
listToolsError: string
notificationHandlers: Map<unknown, (...args: any[]) => any>
closed: boolean
}
const clientStates = new Map<string, MockClientState>()
let lastCreatedClientName: string | undefined
function getOrCreateClientState(name?: string): MockClientState {
const key = name ?? "default"
let state = clientStates.get(key)
if (!state) {
state = {
tools: [],
listToolsShouldFail: false,
listToolsError: "listTools failed",
notificationHandlers: new Map(),
closed: false,
}
clientStates.set(key, state)
}
return state
}
class MockStdioTransport {
stderr: null = null
pid = 12345
// oxlint-disable-next-line no-useless-constructor
constructor(_opts: any) {}
async start() {}
async close() {}
}
class MockStreamableHTTP {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {}
async close() {}
async finishAuth() {}
}
class MockSSE {
// oxlint-disable-next-line no-useless-constructor
constructor(_url: URL, _opts?: any) {}
async start() {}
async close() {}
}
void mock.module("@modelcontextprotocol/sdk/client/stdio.js", () => ({
StdioClientTransport: MockStdioTransport,
}))
void mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: MockStreamableHTTP,
}))
void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: MockSSE,
}))
void mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
UnauthorizedError: class extends Error {
constructor() {
super("Unauthorized")
}
},
}))
void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({
Client: class MockClient {
_state!: MockClientState
transport: any
// oxlint-disable-next-line no-useless-constructor
constructor(_opts: any) {}
async connect(transport: { start: () => Promise<void> }) {
this.transport = transport
await transport.start()
this._state = getOrCreateClientState(lastCreatedClientName)
}
setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) {
this._state?.notificationHandlers.set(schema, handler)
}
async listTools() {
if (this._state?.listToolsShouldFail) {
throw new Error(this._state.listToolsError)
}
return { tools: this._state?.tools ?? [] }
}
async request(req: { method: string }) {
// The fix retries via raw `request("tools/list", ...)` with a
// tolerant schema after the typed listTools() rejects on a bad
// outputSchema reference. The retry path bypasses the SDK's
// strict validator, so the mock returns the same tools list
// without the validation that originally threw.
if (req.method === "tools/list") return { tools: this._state?.tools ?? [] }
throw new Error(`unsupported request: ${req.method}`)
}
async listPrompts() {
return { prompts: [] }
}
async listResources() {
return { resources: [] }
}
async close() {
if (this._state) this._state.closed = true
}
},
}))
beforeEach(() => {
clientStates.clear()
lastCreatedClientName = undefined
})
const { MCP } = await import("../../src/mcp/index")
const { Instance } = await import("../../src/project/instance")
const { WithInstance } = await import("../../src/project/with-instance")
const { tmpdir } = await import("../fixture/fixture")
function withInstance(
config: Record<string, unknown>,
fn: (mcp: MCPNS.Interface) => Effect.Effect<void, unknown, never>,
) {
return async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
`${dir}/opencode.json`,
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: config,
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
await InstanceRuntime.disposeInstance(Instance.current)
},
})
}
}
// ========================================================================
// Reproducer: outputSchema with unresolved $ref fails the whole server
// ========================================================================
//
// In the real bug, the MCP SDK's response-validation layer attempts to
// resolve `$ref`s inside a tool's `outputSchema`. When a referenced
// definition is missing (e.g. `#/$defs/ScreenInstance`), validation
// throws something like:
//
// can't resolve reference #/$defs/ScreenInstance from id #
//
// `client.listTools()` therefore rejects, opencode's `defs()` catches
// the error and returns `undefined`, and `create()` then marks the whole
// MCP server as `failed` -- losing access to all other valid tools the
// server exposes.
//
// This test simulates the same failure path by making `listTools()`
// throw the same error, and asserts the server stays connected with its
// valid tool exposed.
test(
"tool with unresolved $ref in outputSchema does not fail the whole server",
withInstance(
{
"screen-server": {
type: "local",
command: ["echo", "test"],
},
},
(mcp) =>
Effect.gen(function* () {
lastCreatedClientName = "screen-server"
const serverState = getOrCreateClientState("screen-server")
// Simulate the SDK's validation throwing on the bad outputSchema.
// This is exactly what happens in the wild when one tool in
// tools/list has an `outputSchema` like:
// { $ref: "#/$defs/ScreenInstance" }
// with no `$defs` block to resolve against.
serverState.tools = [
{
name: "good_tool",
description: "valid tool that should still load",
inputSchema: { type: "object", properties: {} },
},
{
name: "bad_tool",
description: "tool with unresolved outputSchema $ref",
inputSchema: { type: "object", properties: {} },
outputSchema: { $ref: "#/$defs/ScreenInstance" },
},
]
serverState.listToolsShouldFail = true
serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #"
yield* mcp.add("screen-server", {
type: "local",
command: ["echo", "test"],
})
const status = yield* mcp.status()
// Expected: the server should remain connected because at least
// one tool (`good_tool`) has a valid schema.
expect(status["screen-server"]?.status).toBe("connected")
// Expected: the valid tool should be available even though one
// of the server's tools had a bad outputSchema.
const tools = yield* mcp.tools()
expect(Object.keys(tools).some((k) => k.includes("good_tool"))).toBe(true)
}),
),
)
+5
View File
@@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
// Tests assert exact skill counts from disk discovery; the built-in
// customize-opencode skill is opt-in for stable channels and on by default
// for unstable channels (including "local" where CI runs). Disable it here
// so disk-discovery tests aren't off-by-one.
process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false"
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
@@ -22,8 +22,9 @@ function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
)
}
function uid() {
return SessionID.make(crypto.randomUUID())
function legacySessionID() {
// Global-session migration covers persisted IDs from before prefixed session IDs.
return crypto.randomUUID() as SessionID
}
function seed(opts: { id: SessionID; dir: string; project: ProjectID }) {
@@ -73,7 +74,7 @@ describe("migrateFromGlobal", () => {
expect(pre.id).toBe(ProjectID.global)
// 2. Seed a session under "global" with matching directory
const id = uid()
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 3. Make a commit so the project gets a real ID
@@ -100,7 +101,7 @@ describe("migrateFromGlobal", () => {
// 3. Seed a session under "global" with matching directory.
// This simulates a session created before git init that wasn't
// present when the real project row was first created.
const id = uid()
const id = legacySessionID()
seed({ id, dir: tmp.path, project: ProjectID.global })
// 4. Call fromDirectory again — project row already exists,
@@ -121,7 +122,7 @@ describe("migrateFromGlobal", () => {
// Legacy sessions may lack a directory value.
// Without a matching origin directory, they should remain global.
const id = uid()
const id = legacySessionID()
seed({ id, dir: "", project: ProjectID.global })
await run((svc) => svc.fromDirectory(tmp.path))
@@ -139,7 +140,7 @@ describe("migrateFromGlobal", () => {
ensureGlobal()
// Seed a session under "global" but for a DIFFERENT directory
const id = uid()
const id = legacySessionID()
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
await run((svc) => svc.fromDirectory(tmp.path))
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigProvider } from "@/config/provider"
import { CatalogModelStatus, ModelStatus } from "@/provider/model-status"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
describe("provider model status schemas", () => {
test("keeps catalog status separate from normalized provider status", () => {
expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated")
expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow()
expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active")
})
test("accepts active status across public provider schemas", () => {
expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active")
expect(
Schema.decodeUnknownSync(ModelsDev.Model)({
id: "test-model",
name: "Test Model",
release_date: "2026-01-01",
attachment: false,
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 128000, output: 8192 },
}).status,
).toBeUndefined()
expect(
Schema.decodeUnknownSync(Provider.Model)({
id: "test-model",
providerID: "test-provider",
api: {
id: "test-model",
url: "",
npm: "@ai-sdk/openai-compatible",
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: false,
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: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: { context: 128000, output: 8192 },
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
}).status,
).toBe("active")
})
})
@@ -47,4 +47,41 @@ describe("config HttpApi", () => {
lsp: false,
})
})
test("serves config with active provider model status", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
lsp: false,
provider: {
omniroute: {
models: {
"gpt-4o": {
status: "active",
},
},
},
},
},
})
const response = await app().request("/config", {
headers: {
"x-opencode-directory": tmp.path,
},
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
provider: {
omniroute: {
models: {
"gpt-4o": {
status: "active",
},
},
},
},
})
})
})
@@ -38,6 +38,10 @@ import { type Scenario } from "./types"
void (await import("@opencode-ai/core/util/log")).init({ print: false })
function cursor(input: Record<string, unknown>) {
return Buffer.from(JSON.stringify(input)).toString("base64url")
}
const scenarios: Scenario[] = [
http.protected
.get("/global/health", "global.health")
@@ -520,7 +524,10 @@ const scenarios: Scenario[] = [
yield* ctx.worktreeRemove(ctx.state.directory)
}),
),
http.protected.get("/experimental/session", "experimental.session.list").json(200, array),
http.protected
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
http.protected.get("/experimental/resource", "experimental.resource.list").json(),
http.protected
.post("/sync/history", "sync.history.list")
@@ -598,6 +605,64 @@ const scenarios: Scenario[] = [
},
"none",
),
http.protected
.get("/api/session", "v2.session.list.filters")
.at((ctx) => ({
path: `/api/session?${new URLSearchParams({
limit: "2",
order: "asc",
path: ".",
roots: "false",
start: "0",
search: "missing",
directory: ctx.directory ?? "",
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session", "v2.session.list.cursor")
.at((ctx) => ({
path: `/api/session?${new URLSearchParams({
limit: "2",
directory: ctx.directory ?? "",
cursor: cursor({
id: "ses_httpapi_missing",
time: 0,
order: "desc",
direction: "next",
directory: ctx.directory,
}),
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session", "v2.session.list.cursor.invalid")
.at((ctx) => ({
path: `/api/session?${new URLSearchParams({
cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }),
search: "not-allowed-with-cursor",
})}`,
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.get("/api/session/{sessionID}/context", "v2.session.context")
.at((ctx) => ({
@@ -620,6 +685,53 @@ const scenarios: Scenario[] = [
},
"none",
),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.params")
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
limit: "2",
order: "asc",
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor")
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
limit: "2",
directory: ctx.directory ?? "",
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
})}`,
headers: ctx.headers(),
}))
.json(
200,
(body) => {
object(body)
array(body.items)
object(body.cursor)
},
"none",
),
http.protected
.get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid")
.at((ctx) => ({
path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({
cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }),
order: "asc",
})}`,
headers: ctx.headers(),
}))
.status(400, undefined, "none"),
http.protected
.post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid")
.at((ctx) => ({
@@ -1,14 +1,91 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { SessionID } from "../../src/session/schema"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import {
FilePaths,
FileQuery,
FindFileQuery,
FindTextQuery,
} from "../../src/server/routes/instance/httpapi/groups/file"
import {
ExperimentalPaths,
SessionListQuery as ExperimentalSessionListQuery,
ToolListQuery,
} from "../../src/server/routes/instance/httpapi/groups/experimental"
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
ListQuery as SessionListQuery,
MessagesQuery,
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
type Method = "get" | "post" | "put" | "delete" | "patch"
type QuerySchema = { readonly fields: Record<string, unknown> }
type OpenApiSchema = {
readonly maximum?: number
readonly minimum?: number
readonly pattern?: string
readonly type?: string
}
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
const openApiDriftRoutes = [
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
{ method: "get", path: FilePaths.list, query: FileQuery },
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
{ method: "get", path: "/api/session", query: V2SessionsQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
const numericSdkQueryParams = [
{ method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
{ method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
{ method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
{ method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
{ method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
{ method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
{
method: "get",
path: SessionPaths.messages,
name: "limit",
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
},
{ method: "get", path: "/api/session", name: "limit", schema: { type: "number" } },
{ method: "get", path: "/api/session", name: "start", schema: { type: "number" } },
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
const pathParamPatterns = [
{ method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" },
{ method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" },
{ method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" },
{ method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" },
{ method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" },
{ method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" },
{ method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" },
{ method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" },
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
function app() {
return Server.Default().app
}
@@ -27,6 +104,37 @@ function withTmp<A, E, R>(
).pipe(Effect.flatMap(fn))
}
function openApiPath(path: string) {
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
}
function queryParameters(operation: OpenApiOperation | undefined) {
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
}
function queryParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
}
function pathParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name)
}
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
readonly method: Method
readonly operation: OpenApiOperation | undefined
readonly path: string
readonly query: QuerySchema
}) {
const runtimeFields = new Set(Object.keys(input.query.fields))
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
expect(
advertisedOnly,
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
).toEqual([])
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
@@ -44,6 +152,81 @@ describe("httpapi query schema drift", () => {
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
}
it.effect(
"boolean query schema accepts only true and false strings",
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(QueryBoolean)
const encode = Schema.encodeUnknownSync(QueryBoolean)
expect(decode("true")).toBe(true)
expect(decode("false")).toBe(false)
expect(encode(true)).toBe("true")
expect(encode(false)).toBe("false")
for (const input of ["1", "yes", "True", "", true, false]) {
expect(() => decode(input)).toThrow()
}
}),
)
it.effect(
"OpenAPI workspace query params are declared by runtime query schemas",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const route of openApiDriftRoutes) {
assertAdvertisedQueryParamsAreRuntimeFields({
...route,
operation: spec.paths[openApiPath(route.path)]?.[route.method],
})
}
}),
)
it.effect(
"OpenAPI numeric query params preserve generated SDK call shapes",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const expected of numericSdkQueryParams) {
expect(
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual(expected.schema)
}
}),
)
it.effect(
"OpenAPI path parameter patterns come from runtime schemas",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const expected of pathParamPatterns) {
expect(
pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual({ type: "string", pattern: expected.pattern })
}
}),
)
it.effect(
"drift assertion catches spec-only workspace query params",
Effect.sync(() => {
expect(() =>
assertAdvertisedQueryParamsAreRuntimeFields({
method: "get",
operation: {
parameters: [
{ name: "directory", in: "query" },
{ name: "workspace", in: "query" },
],
},
path: "/fixture",
query: { fields: {} },
}),
).toThrow("advertises query params not accepted by runtime schema")
}),
)
it.live(
"session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
@@ -287,7 +287,7 @@ describe("HttpApi UI fallback", () => {
})
test("keeps matched API routes ahead of the UI fallback", async () => {
const response = await Server.Default().app.request("/session/nope")
const response = await Server.Default().app.request("/session/ses_nope")
expect(response.status).toBe(404)
})
@@ -0,0 +1,97 @@
// Regression: a stored step-finish part with a negative token count made the
// messages endpoint 400. Some providers reported `outputTokens` excluding
// reasoning while also reporting `reasoningTokens` separately, so the
// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
// strict `NonNegativeInt` schema then made every load of the message list
// fail to encode, killing Desktop boot for every user with such a row.
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { eq } from "drizzle-orm"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID } from "../../src/session/schema"
import * as Database from "@/storage/db"
import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function seedNegativeTokenSession(directory: string) {
return Effect.promise(async () =>
WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run(),
)
return info.id
}).pipe(Effect.provide(Session.defaultLayer)),
),
}),
)
}
describe("messages endpoint tolerates legacy negative token counts", () => {
it.live(
"returns 200 even when a step-finish part has tokens.output < 0",
Effect.acquireRelease(
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const sessionID = yield* seedNegativeTokenSession(tmp.path)
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
),
),
)
})
@@ -61,7 +61,7 @@ const tmpWithFiles = (files: Record<string, string>) =>
function loaded(filepath: string): MessageV2.WithParts[] {
const sessionID = SessionID.make("session-loaded-1")
const messageID = MessageID.make("message-loaded-1")
const messageID = MessageID.make("msg_message-loaded-1")
return [
{
@@ -78,7 +78,7 @@ function loaded(filepath: string): MessageV2.WithParts[] {
},
parts: [
{
id: PartID.make("part-loaded-1"),
id: PartID.make("prt_part-loaded-1"),
messageID,
sessionID,
type: "tool",
@@ -106,7 +106,7 @@ describe("Instruction.resolve", () => {
const system = yield* svc.systemPaths()
expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true)
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1"))
const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1"))
expect(results).toEqual([])
}),
),
@@ -122,7 +122,7 @@ describe("Instruction.resolve", () => {
const results = yield* svc.resolve(
[],
path.join(dir, "subdir", "nested", "file.ts"),
MessageID.make("message-test-2"),
MessageID.make("msg_message-test-2"),
)
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md"))
@@ -138,7 +138,7 @@ describe("Instruction.resolve", () => {
const system = yield* svc.systemPaths()
expect(system.has(filepath)).toBe(false)
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3"))
expect(results).toEqual([])
}),
),
@@ -149,7 +149,7 @@ describe("Instruction.resolve", () => {
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-1")
const id = MessageID.make("msg_message-claim-1")
const first = yield* svc.resolve([], filepath, id)
const second = yield* svc.resolve([], filepath, id)
@@ -166,7 +166,7 @@ describe("Instruction.resolve", () => {
Effect.gen(function* () {
const svc = yield* Instruction.Service
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-2")
const id = MessageID.make("msg_message-claim-2")
const first = yield* svc.resolve([], filepath, id)
yield* svc.clear(id)
@@ -185,7 +185,7 @@ describe("Instruction.resolve", () => {
const svc = yield* Instruction.Service
const agents = path.join(dir, "subdir", "AGENTS.md")
const filepath = path.join(dir, "subdir", "nested", "file.ts")
const id = MessageID.make("message-claim-3")
const id = MessageID.make("msg_message-claim-3")
const results = yield* svc.resolve(loaded(agents), filepath, id)
expect(results).toEqual([])
+8 -8
View File
@@ -354,7 +354,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-1"),
id: MessageID.make("msg_user-1"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -438,7 +438,7 @@ describe("session.llm.stream", () => {
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-service-abort"),
id: MessageID.make("msg_user-service-abort"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -529,7 +529,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-tools"),
id: MessageID.make("msg_user-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -644,7 +644,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-2"),
id: MessageID.make("msg_user-2"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -759,7 +759,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-data-url"),
id: MessageID.make("msg_user-data-url"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -880,7 +880,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-3"),
id: MessageID.make("msg_user-3"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -995,7 +995,7 @@ describe("session.llm.stream", () => {
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("user-anthropic-tools"),
id: MessageID.make("msg_user-anthropic-tools"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -1239,7 +1239,7 @@ describe("session.llm.stream", () => {
} satisfies Agent.Info
const user = {
id: MessageID.make("user-4"),
id: MessageID.make("msg_user-4"),
sessionID,
role: "user",
time: { created: Date.now() },
@@ -102,9 +102,9 @@ function assistantInfo(
function basePart(messageID: string, id: string) {
return {
id: PartID.make(id),
id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`),
sessionID,
messageID: MessageID.make(messageID),
messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`),
}
}
@@ -27,7 +27,7 @@ const runtime = ManagedRuntime.make(
const baseCtx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -17,7 +17,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
const ctx = {
sessionID: SessionID.make("ses_test-edit-session"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
@@ -12,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -23,7 +23,7 @@ const it = testEffect(
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -23,7 +23,7 @@ const it = testEffect(
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -20,7 +20,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -10,7 +10,7 @@ import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-session"),
messageID: MessageID.make("test-message"),
messageID: MessageID.make("msg_test-message"),
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -24,7 +24,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
@@ -19,7 +19,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),
@@ -18,7 +18,7 @@ afterEach(async () => {
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "scout",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -36,7 +36,7 @@ const initShell = initBash
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -13,7 +13,7 @@ const projectRoot = path.join(import.meta.dir, "../..")
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("message"),
messageID: MessageID.make("msg_message"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect"
const ctx = {
sessionID: SessionID.make("ses_test-write-session"),
messageID: MessageID.make(""),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.44",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.44",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1065,7 +1065,7 @@ export type ProviderConfig = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status?: "alpha" | "beta" | "deprecated" | "active"
options?: {
[key: string]: unknown
}
@@ -3012,7 +3012,7 @@ export type ProviderListResponses = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status?: "alpha" | "beta" | "deprecated" | "active"
options: {
[key: string]: unknown
}
+20
View File
@@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
path?: string
roots?: boolean | "true" | "false"
start?: number
search?: string
cursor?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "order" },
{ in: "query", key: "path" },
{ in: "query", key: "roots" },
{ in: "query", key: "start" },
{ in: "query", key: "search" },
{ in: "query", key: "cursor" },
],
},
],
@@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient {
sessionID: string
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
cursor?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient {
{ in: "path", key: "sessionID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "limit" },
{ in: "query", key: "order" },
{ in: "query", key: "cursor" },
],
},
],
+17 -1
View File
@@ -1060,7 +1060,7 @@ export type ProviderConfig = {
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
status?: "alpha" | "beta" | "deprecated" | "active"
provider?: {
npm?: string
api?: string
@@ -6235,6 +6235,16 @@ export type V2SessionListData = {
query?: {
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
path?: string
roots?: boolean | "true" | "false"
start?: number
search?: string
/**
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.
*/
cursor?: string
}
url: "/api/session"
}
@@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = {
query?: {
directory?: string
workspace?: string
limit?: number
order?: "asc" | "desc"
/**
* Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.
*/
cursor?: string
}
url: "/api/session/{sessionID}/message"
}
+1581 -1351
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.44",
"version": "1.14.46",
"type": "module",
"license": "MIT",
"scripts": {

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