Przeglądaj źródła

Fix OpenAPI workspace query drift (#26609)

Kit Langton 3 miesięcy temu
rodzic
commit
e30d8173c1
26 zmienionych plików z 752 dodań i 212 usunięć
  1. 204 0
      packages/opencode/specs/openapi-translation-cleanup.md
  2. 2 0
      packages/opencode/src/server/routes/instance/httpapi/event.ts
  3. 4 1
      packages/opencode/src/server/routes/instance/httpapi/groups/config.ts
  4. 14 1
      packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts
  5. 6 1
      packages/opencode/src/server/routes/instance/httpapi/groups/file.ts
  6. 16 1
      packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts
  7. 9 1
      packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
  8. 3 1
      packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts
  9. 5 1
      packages/opencode/src/server/routes/instance/httpapi/groups/project.ts
  10. 5 1
      packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts
  11. 13 1
      packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts
  12. 4 1
      packages/opencode/src/server/routes/instance/httpapi/groups/question.ts
  13. 29 1
      packages/opencode/src/server/routes/instance/httpapi/groups/session.ts
  14. 5 1
      packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts
  15. 14 1
      packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts
  16. 20 35
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts
  17. 35 53
      packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts
  18. 8 1
      packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts
  19. 1 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts
  20. 27 0
      packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts
  21. 2 0
      packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts
  22. 4 27
      packages/opencode/src/server/routes/instance/httpapi/public.ts
  23. 109 0
      packages/opencode/test/server/httpapi-exercise/index.ts
  24. 94 0
      packages/opencode/test/server/httpapi-query-schema-drift.test.ts
  25. 20 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  26. 99 83
      packages/sdk/js/src/v2/gen/types.gen.ts

+ 204 - 0
packages/opencode/specs/openapi-translation-cleanup.md

@@ -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:
+
+- Replace `roots` / `archived` reliance on `QueryBooleanParameters` with explicit route schema helpers.
+- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing.
+- 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:
+
+- `sessionID`
+- `messageID`
+- `partID`
+- `permissionID`
+- `ptyID`
+
+Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params.
+
+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`.

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/event.ts

@@ -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({

+ 4 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/config.ts

@@ -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({

+ 14 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts

@@ -8,7 +8,11 @@ import { Schema, SchemaGetter } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
+import {
+  WorkspaceRoutingMiddleware,
+  WorkspaceRoutingQuery,
+  WorkspaceRoutingQueryFields,
+} from "../middleware/workspace-routing"
 import { described } from "./metadata"
 
 const ConsoleStateResponse = Schema.Struct({
@@ -82,6 +86,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 +97,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 +108,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 +132,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 +144,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 +154,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 +166,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 +178,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 +201,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({

+ 6 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/file.ts

@@ -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({

+ 16 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts

@@ -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({

+ 9 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts

@@ -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({

+ 3 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts

@@ -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],

+ 5 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/project.ts

@@ -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 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts

@@ -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,

+ 13 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts

@@ -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(

+ 4 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/question.ts

@@ -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(

+ 29 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

@@ -14,7 +14,11 @@ import { Schema, SchemaGetter, Struct } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
+import {
+  WorkspaceRoutingMiddleware,
+  WorkspaceRoutingQuery,
+  WorkspaceRoutingQueryFields,
+} from "../middleware/workspace-routing"
 import { ApiNotFoundError } from "../errors"
 import { described } from "./metadata"
 
@@ -116,6 +120,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 +132,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 +144,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 +156,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 +191,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 +202,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 +215,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 +227,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 +240,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 +253,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 +265,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 +279,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 +291,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 +303,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 +316,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 +329,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 +343,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 +356,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 +369,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 +383,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 +395,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 +409,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 +422,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 +433,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],

+ 5 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts

@@ -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,

+ 14 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts

@@ -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(

+ 20 - 35
packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts

@@ -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({

+ 35 - 53
packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts

@@ -1,4 +1,3 @@
-import { WorkspaceID } from "@/control-plane/schema"
 import { SessionID } from "@/session/schema"
 import { SessionMessage } from "@/v2/session-message"
 import { Prompt } from "@/v2/session-prompt"
@@ -6,62 +5,41 @@ import { SessionV2 } from "@/v2/session"
 import { Schema, SchemaGetter } from "effect"
 import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../../middleware/authorization"
+import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
+
+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 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 +60,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 +77,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 +90,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 +103,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({

+ 8 - 1
packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts

@@ -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],

+ 1 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts

@@ -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({}),

+ 27 - 0
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts

@@ -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,

+ 2 - 0
packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts

@@ -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<{

+ 4 - 27
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -51,30 +51,14 @@ 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 QueryNumberParameters = new Set(["start", "limit", "method"])
 const QueryBooleanParameters = new Set(["roots", "archived"])
 const QueryParameterSchemas = {
   "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 },
+  "GET /experimental/session cursor": { type: "number" },
   "GET /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>
@@ -122,7 +106,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 +166,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

+ 109 - 0
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -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")
@@ -598,6 +602,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 +682,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) => ({

+ 94 - 0
packages/opencode/test/server/httpapi-query-schema-drift.test.ts

@@ -1,14 +1,53 @@
 import { afterEach, describe, expect } from "bun:test"
 import { Effect } 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 {
+  ListQuery as SessionListQuery,
+  MessagesQuery,
+  SessionPaths,
+} from "../../src/server/routes/instance/httpapi/groups/session"
+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 { 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 OpenApiParameter = { readonly name: string; readonly in: string }
+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 }>
+
 function app() {
   return Server.Default().app
 }
@@ -27,6 +66,29 @@ 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 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 +106,38 @@ describe("httpapi query schema drift", () => {
     expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
   }
 
+  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(
+    "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) =>

+ 20 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -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" },
           ],
         },
       ],

+ 99 - 83
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -5,6 +5,12 @@ export type ClientOptions = {
 }
 
 export type Event =
+  | EventTuiPromptAppend
+  | EventTuiCommandExecute
+  | EventTuiToastShow1
+  | EventTuiSessionSelect
+  | EventServerConnected
+  | EventGlobalDisposed
   | EventServerInstanceDisposed
   | EventFileEdited
   | EventFileWatcherUpdated
@@ -24,10 +30,6 @@ export type Event =
   | EventSessionStatus
   | EventSessionIdle
   | EventSessionCompacted
-  | EventTuiPromptAppend
-  | EventTuiCommandExecute
-  | EventTuiToastShow1
-  | EventTuiSessionSelect
   | EventMcpToolsChanged
   | EventMcpBrowserOpenFailed
   | EventCommandExecuted
@@ -75,8 +77,6 @@ export type Event =
   | EventSessionNextCompactionStarted
   | EventSessionNextCompactionDelta
   | EventSessionNextCompactionEnded
-  | EventServerConnected
-  | EventGlobalDisposed
 
 export type OAuth = {
   type: "oauth"
@@ -103,6 +103,61 @@ export type WellKnownAuth = {
 
 export type Auth = OAuth | ApiAuth | WellKnownAuth
 
+export type EventTuiPromptAppend = {
+  id: string
+  type: "tui.prompt.append"
+  properties: {
+    text: string
+  }
+}
+
+export type EventTuiCommandExecute = {
+  id: string
+  type: "tui.command.execute"
+  properties: {
+    command:
+      | "session.list"
+      | "session.new"
+      | "session.share"
+      | "session.interrupt"
+      | "session.compact"
+      | "session.page.up"
+      | "session.page.down"
+      | "session.line.up"
+      | "session.line.down"
+      | "session.half.page.up"
+      | "session.half.page.down"
+      | "session.first"
+      | "session.last"
+      | "prompt.clear"
+      | "prompt.submit"
+      | "agent.cycle"
+      | string
+  }
+}
+
+export type EventTuiToastShow = {
+  id: string
+  type: "tui.toast.show"
+  properties: {
+    title?: string
+    message: string
+    variant: "info" | "success" | "warning" | "error"
+    duration?: number
+  }
+}
+
+export type EventTuiSessionSelect = {
+  id: string
+  type: "tui.session.select"
+  properties: {
+    /**
+     * Session ID to navigate to
+     */
+    sessionID: string
+  }
+}
+
 export type PermissionRequest = {
   id: string
   sessionID: string
@@ -280,61 +335,6 @@ export type SessionStatus =
       type: "busy"
     }
 
-export type EventTuiPromptAppend = {
-  id: string
-  type: "tui.prompt.append"
-  properties: {
-    text: string
-  }
-}
-
-export type EventTuiCommandExecute = {
-  id: string
-  type: "tui.command.execute"
-  properties: {
-    command:
-      | "session.list"
-      | "session.new"
-      | "session.share"
-      | "session.interrupt"
-      | "session.compact"
-      | "session.page.up"
-      | "session.page.down"
-      | "session.line.up"
-      | "session.line.down"
-      | "session.half.page.up"
-      | "session.half.page.down"
-      | "session.first"
-      | "session.last"
-      | "prompt.clear"
-      | "prompt.submit"
-      | "agent.cycle"
-      | string
-  }
-}
-
-export type EventTuiToastShow = {
-  id: string
-  type: "tui.toast.show"
-  properties: {
-    title?: string
-    message: string
-    variant: "info" | "success" | "warning" | "error"
-    duration?: number
-  }
-}
-
-export type EventTuiSessionSelect = {
-  id: string
-  type: "tui.session.select"
-  properties: {
-    /**
-     * Session ID to navigate to
-     */
-    sessionID: string
-  }
-}
-
 export type Project = {
   id: string
   worktree: string
@@ -778,6 +778,12 @@ export type GlobalEvent = {
   project?: string
   workspace?: string
   payload:
+    | EventTuiPromptAppend
+    | EventTuiCommandExecute
+    | EventTuiToastShow
+    | EventTuiSessionSelect
+    | EventServerConnected
+    | EventGlobalDisposed
     | EventServerInstanceDisposed
     | EventFileEdited
     | EventFileWatcherUpdated
@@ -797,10 +803,6 @@ export type GlobalEvent = {
     | EventSessionStatus
     | EventSessionIdle
     | EventSessionCompacted
-    | EventTuiPromptAppend
-    | EventTuiCommandExecute
-    | EventTuiToastShow
-    | EventTuiSessionSelect
     | EventMcpToolsChanged
     | EventMcpBrowserOpenFailed
     | EventCommandExecuted
@@ -848,8 +850,6 @@ export type GlobalEvent = {
     | EventSessionNextCompactionStarted
     | EventSessionNextCompactionDelta
     | EventSessionNextCompactionEnded
-    | EventServerConnected
-    | EventGlobalDisposed
     | SyncEventMessageUpdated
     | SyncEventMessageRemoved
     | SyncEventMessagePartUpdated
@@ -2318,6 +2318,22 @@ export type SyncEventSessionNextCompactionEnded = {
   }
 }
 
+export type EventServerConnected = {
+  id: string
+  type: "server.connected"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
+export type EventGlobalDisposed = {
+  id: string
+  type: "global.disposed"
+  properties: {
+    [key: string]: unknown
+  }
+}
+
 export type EventServerInstanceDisposed = {
   id: string
   type: "server.instance.disposed"
@@ -3032,22 +3048,6 @@ export type EventSessionNextCompactionEnded = {
   }
 }
 
-export type EventServerConnected = {
-  id: string
-  type: "server.connected"
-  properties: {
-    [key: string]: unknown
-  }
-}
-
-export type EventGlobalDisposed = {
-  id: string
-  type: "global.disposed"
-  properties: {
-    [key: string]: unknown
-  }
-}
-
 export type SessionInfo = {
   id: string
   parentID?: 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"
 }