feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture

Forked from OpenCode v1.17.4 with multi-agent system:
- 5 agents: aircoding, scheduler, worker, architect, reviewer
- Deterministic DAG scheduling engine (coordinator_tick)
- Tool whitelists as hard enforcement
- AirCoding validation plugin
- System prompt injection for routing
- V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md
- Design documents in docs/
This commit is contained in:
airlongdian
2026-06-13 21:41:54 +08:00
commit af3016fe27
5757 changed files with 1170017 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.4",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
"./*": "./src/*.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

View File

@@ -0,0 +1,44 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
import { MessageGroup } from "./groups/message"
import { ModelGroup } from "./groups/model"
import { ProviderGroup } from "./groups/provider"
import { SessionGroup } from "./groups/session"
import { PermissionGroup } from "./groups/permission"
import { FileSystemGroup } from "./groups/fs"
import { CommandGroup } from "./groups/command"
import { SkillGroup } from "./groups/skill"
import { EventGroup } from "./groups/event"
import { AgentGroup } from "./groups/agent"
import { HealthGroup } from "./groups/health"
import { QuestionGroup } from "./groups/question"
import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
import { ConnectorGroup } from "./groups/connector"
export const Api = HttpApi.make("server")
.add(HealthGroup)
.add(LocationGroup)
.add(AgentGroup)
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(ConnectorGroup)
.add(PermissionGroup)
.add(FileSystemGroup)
.add(CommandGroup)
.add(SkillGroup)
.add(EventGroup)
.add(QuestionGroup)
.add(ReferenceGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
.middleware(Authorization)
.middleware(SchemaErrorMiddleware)

View File

@@ -0,0 +1,63 @@
export * as ServerAuth from "./auth"
import { Config as EffectConfig, Context, Effect, Layer, Option, Redacted } from "effect"
export type Credentials = {
password?: string
username?: string
}
export type DecodedCredentials = {
readonly username: string
readonly password: Redacted.Redacted
}
export type Info = {
readonly password: Option.Option<string>
readonly username: string
}
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
static layer(input: Info) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Effect.gen(function* () {
return Config.of(
yield* EffectConfig.all({
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
}),
)
}),
)
}
}
export function required(config: Info) {
return Option.isSome(config.password) && config.password.value !== ""
}
export function authorized(credentials: DecodedCredentials, config: Info) {
return (
Option.isSome(config.password) &&
credentials.username === config.username &&
Redacted.value(credentials.password) === config.password.value
)
}
export function header(credentials?: Credentials) {
const password = credentials?.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
return `Basic ${Buffer.from(`${credentials?.username ?? process.env.OPENCODE_SERVER_USERNAME ?? "opencode"}:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {
const authorization = header(credentials)
if (!authorization) return undefined
return { Authorization: authorization }
}

View File

@@ -0,0 +1,86 @@
import { Schema } from "effect"
export class InvalidRequestError extends Schema.TaggedErrorClass<InvalidRequestError>()(
"InvalidRequestError",
{
message: Schema.String,
kind: Schema.optional(Schema.String),
field: Schema.optional(Schema.String),
},
{ httpApiStatus: 400 },
) {}
export class UnauthorizedError extends Schema.TaggedErrorClass<UnauthorizedError>()(
"UnauthorizedError",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
export class ConflictError extends Schema.TaggedErrorClass<ConflictError>()(
"ConflictError",
{
message: Schema.String,
resource: Schema.optional(Schema.String),
},
{ httpApiStatus: 409 },
) {}
export class ServiceUnavailableError extends Schema.TaggedErrorClass<ServiceUnavailableError>()(
"ServiceUnavailableError",
{
message: Schema.String,
service: Schema.optional(Schema.String),
},
{ httpApiStatus: 503 },
) {}
export class UnknownError extends Schema.TaggedErrorClass<UnknownError>()(
"UnknownError",
{
message: Schema.String,
ref: Schema.optional(Schema.String),
},
{ httpApiStatus: 500 },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"ProviderNotFoundError",
{
providerID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"SessionNotFoundError",
{
sessionID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()(
"InvalidCursorError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionNotFoundError>()(
"PermissionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
"QuestionNotFoundError",
{
requestID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}

View File

@@ -0,0 +1,22 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const AgentGroup = HttpApiGroup.make("server.agent")
.add(
HttpApiEndpoint.get("agent.list", "/api/agent", {
query: LocationQuery,
success: Location.response(Schema.Array(AgentV2.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.agent.list",
summary: "List agents",
description: "Retrieve currently registered agents.",
}),
),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,28 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const CommandGroup = HttpApiGroup.make("server.command")
.add(
HttpApiEndpoint.get("command.list", "/api/command", {
query: LocationQuery,
success: Location.response(Schema.Array(CommandV2.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.command.list",
summary: "List commands",
description: "Retrieve currently registered commands.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "commands",
description: "Experimental command routes.",
}),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,133 @@
import { Connector } from "@opencode-ai/core/connector"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
const Inputs = Schema.Record(Schema.String, Schema.String)
export const ConnectorGroup = HttpApiGroup.make("server.connector")
.add(
HttpApiEndpoint.get("connector.list", "/api/connector", {
query: LocationQuery,
success: Location.response(Schema.Array(Connector.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.list",
summary: "List connectors",
description: "Retrieve available connectors and their authentication methods.",
}),
),
)
.add(
HttpApiEndpoint.get("connector.get", "/api/connector/:connectorID", {
params: { connectorID: Connector.ID },
query: LocationQuery,
success: Location.response(Schema.UndefinedOr(Connector.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.get",
summary: "Get connector",
description: "Retrieve one connector and its authentication methods.",
}),
),
)
.add(
HttpApiEndpoint.post("connector.connect.key", "/api/connector/:connectorID/connect/key", {
params: { connectorID: Connector.ID },
query: LocationQuery,
payload: Schema.Struct({
methodID: Connector.MethodID,
key: Schema.String,
inputs: Inputs,
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.key",
summary: "Connect with key",
description: "Run a key authentication method and store the resulting credential.",
}),
),
)
.add(
HttpApiEndpoint.post("connector.connect.oauth.begin", "/api/connector/:connectorID/connect/oauth", {
params: { connectorID: Connector.ID },
query: LocationQuery,
payload: Schema.Struct({
methodID: Connector.MethodID,
inputs: Inputs,
label: Schema.optional(Schema.String),
}),
success: Location.response(Connector.Attempt),
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.begin",
summary: "Begin OAuth connection",
description: "Start an OAuth attempt and return the authorization details.",
}),
),
)
.add(
HttpApiEndpoint.get("connector.connect.oauth.status", "/api/connector/oauth/:attemptID", {
params: { attemptID: Connector.AttemptID },
query: LocationQuery,
success: Location.response(Connector.AttemptStatus),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.status",
summary: "Get OAuth attempt status",
description: "Poll the current status of an OAuth attempt.",
}),
),
)
.add(
HttpApiEndpoint.post("connector.connect.oauth.complete", "/api/connector/oauth/:attemptID/complete", {
params: { attemptID: Connector.AttemptID },
query: LocationQuery,
payload: Schema.Struct({ code: Schema.optional(Schema.String) }),
success: HttpApiSchema.NoContent,
error: InvalidRequestError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.complete",
summary: "Complete OAuth connection",
description: "Complete a code-based OAuth attempt and store the resulting credential.",
}),
),
)
.add(
HttpApiEndpoint.delete("connector.connect.oauth.cancel", "/api/connector/oauth/:attemptID", {
params: { attemptID: Connector.AttemptID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.connector.connect.oauth.cancel",
summary: "Cancel OAuth connection",
description: "Cancel an OAuth attempt and release its resources.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "connectors", description: "Connector discovery and authentication routes." }),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,34 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
const Event = Schema.Struct({
id: EventV2.ID,
type: Schema.String,
location: Location.Info.pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
version: Schema.Number.pipe(Schema.optional),
data: Schema.Unknown,
})
export const EventGroup = HttpApiGroup.make("server.event")
.add(
HttpApiEndpoint.get("event.subscribe", "/api/event", {
query: LocationQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
summary: "Subscribe to events",
description: "Subscribe to native event payloads for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "events", description: "Experimental event stream route." }))
.middleware(LocationMiddleware)
export type Event = typeof Event.Type

View File

@@ -0,0 +1,69 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { PositiveInt, RelativePath } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
const ListQuery = Schema.Struct({
...LocationQuery.fields,
path: RelativePath.pipe(Schema.optional),
})
const FindQuery = Schema.Struct({
...LocationQuery.fields,
query: FileSystem.FindInput.fields.query,
type: FileSystem.FindInput.fields.type,
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional),
})
export const FileSystemGroup = HttpApiGroup.make("server.fs")
.add(
HttpApiEndpoint.get("fs.read", "/api/fs/read/*", {
query: LocationQuery,
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.read",
summary: "Read file",
description: "Serve one file relative to the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("fs.list", "/api/fs/list", {
query: ListQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.list",
summary: "List directory",
description: "List direct children of one directory relative to the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("fs.find", "/api/fs/find", {
query: FindQuery,
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.find",
summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "filesystem",
description: "Experimental location-scoped filesystem routes.",
}),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,14 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const HealthGroup = HttpApiGroup.make("server.health").add(
HttpApiEndpoint.get("health.get", "/api/health", {
success: Schema.Struct({ healthy: Schema.Literal(true) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.get",
summary: "Check server health",
description: "Check whether the API server is ready to accept requests.",
}),
),
)

View File

@@ -0,0 +1,96 @@
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
}),
),
}).annotate({ identifier: "LocationQuery" })
export const locationQueryOpenApi = OpenApi.annotations({
transform: (operation) => {
const parameters = operation.parameters
if (!Array.isArray(parameters)) return operation
return {
...operation,
parameters: parameters.map((parameter) =>
parameter?.name === "location" && parameter?.in === "query"
? { ...parameter, style: "deepObject", explode: true }
: parameter,
),
}
},
})
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const location = yield* Location.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* data,
}
})
}
export type LocationServices = Layer.Success<ReturnType<typeof LocationServiceMap.get>>
export class LocationMiddleware extends HttpApiMiddleware.Service<
LocationMiddleware,
{
provides: LocationServices
}
>()("@opencode/HttpApiLocation") {}
export const LocationGroup = HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.Info,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.middleware(LocationMiddleware)
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
const query = new URL(request.url, "http://localhost").searchParams
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
return Location.Ref.make({
directory: AbsolutePath.make(
query.get("location[directory]") || request.headers["x-opencode-directory"] || process.cwd(),
),
workspaceID: workspaceID ? WorkspaceV2.ID.make(workspaceID) : undefined,
})
}
export const layer = Layer.effect(
LocationMiddleware,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
}),
)
}),
)

View File

@@ -0,0 +1,54 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
export const SessionMessagesQuery = 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.String.annotate({
description:
"Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.",
}),
),
}).annotate({ identifier: "SessionMessagesQuery" })
export const MessageGroup = HttpApiGroup.make("server.message")
.add(
HttpApiEndpoint.get("session.messages", "/api/session/:sessionID/message", {
params: { sessionID: SessionV2.ID },
query: SessionMessagesQuery,
success: Schema.Struct({
data: Schema.Array(SessionMessage.Message),
cursor: Schema.Struct({
previous: Schema.String.pipe(Schema.optional),
next: Schema.String.pipe(Schema.optional),
}),
}).annotate({ identifier: "SessionMessagesResponse" }),
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.messages",
summary: "Get session messages",
description:
"Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "messages",
description: "Experimental message routes.",
}),
)

View File

@@ -0,0 +1,30 @@
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const ModelGroup = HttpApiGroup.make("server.model")
.add(
HttpApiEndpoint.get("model.list", "/api/model", {
query: LocationQuery,
success: Location.response(Schema.Array(ModelV2.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List models",
description: "Retrieve available models ordered by release date.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "models",
description: "Experimental model routes.",
}),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,86 @@
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Location } from "@opencode-ai/core/location"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const PermissionGroup = HttpApiGroup.make("server.permission")
.add(
HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
query: LocationQuery,
success: Location.response(Schema.Array(PermissionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.add(
HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
),
)
.add(
HttpApiEndpoint.delete("permission.saved.remove", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
),
)
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(PermissionV2.Request) }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permissions", description: "Experimental permission routes." }))

View File

@@ -0,0 +1,46 @@
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProviderNotFoundError, ServiceUnavailableError } from "../errors"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const ProviderGroup = HttpApiGroup.make("server.provider")
.add(
HttpApiEndpoint.get("provider.list", "/api/provider", {
query: LocationQuery,
success: Location.response(Schema.Array(ProviderV2.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
summary: "List providers",
description: "Retrieve active AI providers so clients can show provider availability and configuration.",
}),
),
)
.add(
HttpApiEndpoint.get("provider.get", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
query: LocationQuery,
success: Location.response(ProviderV2.Info),
error: [ProviderNotFoundError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
summary: "Get provider",
description: "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "providers",
description: "Experimental provider routes.",
}),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,75 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Location } from "@opencode-ai/core/location"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { QuestionNotFoundError, SessionNotFoundError } from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const QuestionGroup = HttpApiGroup.make("server.question")
.add(
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
query: LocationQuery,
success: Location.response(Schema.Array(QuestionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.question.request.list",
summary: "List pending question requests",
description: "Retrieve pending question requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
.middleware(LocationMiddleware)
.add(
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.list",
summary: "List session question requests",
description: "Retrieve pending question requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
payload: QuestionV2.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reply",
summary: "Reply to pending question request",
description: "Answer a pending question request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, QuestionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.question.reject",
summary: "Reject pending question request",
description: "Reject a pending question request owned by a session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
)

View File

@@ -0,0 +1,28 @@
import { Location } from "@opencode-ai/core/location"
import { Reference } from "@opencode-ai/core/reference"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location"
export const ReferenceGroup = HttpApiGroup.make("server.reference")
.add(
HttpApiEndpoint.get("reference.list", "/api/reference", {
query: LocationQuery,
success: Location.response(Schema.Array(Reference.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.reference.list",
summary: "List references",
description: "List references available in the requested location.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "reference",
description: "Location-scoped project references.",
}),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,214 @@
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionV2 } from "@opencode-ai/core/session"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
InvalidCursorError,
InvalidRequestError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../errors"
import { SessionLocationMiddleware } from "../middleware/session-location"
import { AgentV2 } from "@opencode-ai/core/agent"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
const SessionsQueryFields = {
workspace: WorkspaceV2.ID.pipe(Schema.optional),
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).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.",
}),
search: Schema.optional(Schema.String),
}
const SessionsDirectoryQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath,
})
const SessionsProjectQuery = Schema.Struct({
...SessionsQueryFields,
project: ProjectV2.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({
...Struct.omit(fields, ["limit"]),
anchor: SessionV2.ListAnchor,
}))
const SessionsCursorInput = Schema.Union([
withCursor(SessionsDirectoryQuery),
withCursor(SessionsProjectQuery),
withCursor(SessionsAllQuery),
])
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
export const SessionsCursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
withStatics((schema) => {
const make = schema.make
return {
make: (input: typeof SessionsCursorInput.Type) =>
make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
}
}),
)
export type SessionsCursor = typeof SessionsCursor.Type
const SessionsCursorQuery = Schema.Struct({
cursor: SessionsCursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}),
limit: SessionsQueryFields.limit,
})
export const SessionsQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath.pipe(Schema.optional),
project: ProjectV2.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const SessionGroup = HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
query: SessionsQuery,
success: Schema.Struct({
data: Schema.Array(SessionV2.Info),
cursor: Schema.Struct({
previous: SessionsCursor.pipe(Schema.optional),
next: SessionsCursor.pipe(Schema.optional),
}),
}).annotate({ identifier: "SessionsResponse" }),
error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
summary: "List sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
}),
),
)
.add(
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: SessionV2.ID.pipe(Schema.optional),
agent: AgentV2.ID.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionV2.Info }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
),
)
.add(
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: SessionV2.Info }),
error: SessionNotFoundError,
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
}),
),
)
.add(
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
params: { sessionID: SessionV2.ID },
payload: Schema.Struct({
id: SessionMessage.ID.pipe(Schema.optional),
prompt: Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
error: [ConflictError, SessionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send message",
description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
}),
),
)
.add(
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
summary: "Compact session",
description: "Compact a session conversation.",
}),
),
)
.add(
HttpApiEndpoint.post("session.wait", "/api/session/:sessionID/wait", {
params: { sessionID: SessionV2.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
summary: "Wait for session",
description: "Wait for a session agent loop to become idle.",
}),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
params: { sessionID: SessionV2.ID },
success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }),
error: [SessionNotFoundError, UnknownError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
summary: "Get session context",
description: "Retrieve the active context messages for a session (all messages after the last compaction).",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "sessions",
description: "Experimental session routes.",
}),
)

View File

@@ -0,0 +1,28 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location"
export const SkillGroup = HttpApiGroup.make("server.skill")
.add(
HttpApiEndpoint.get("skill.list", "/api/skill", {
query: LocationQuery,
success: Location.response(Schema.Array(SkillV2.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.skill.list",
summary: "List skills",
description: "Retrieve currently registered skills.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "skills",
description: "Experimental skill routes.",
}),
)
.middleware(LocationMiddleware)

View File

@@ -0,0 +1,47 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Layer } from "effect"
import { layer as locationLayer } from "./groups/location"
import { sessionLocationLayer } from "./middleware/session-location"
import { MessageHandler } from "./handlers/message"
import { ModelHandler } from "./handlers/model"
import { ProviderHandler } from "./handlers/provider"
import { SessionHandler } from "./handlers/session"
import { PermissionHandler } from "./handlers/permission"
import { FileSystemHandler } from "./handlers/fs"
import { CommandHandler } from "./handlers/command"
import { SkillHandler } from "./handlers/skill"
import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health"
import { QuestionHandler } from "./handlers/question"
import { ReferenceHandler } from "./handlers/reference"
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
import { LocationHandler } from "./handlers/location"
import { ConnectorHandler } from "./handlers/connector"
export const handlers = Layer.mergeAll(
HealthHandler,
LocationHandler,
AgentHandler,
SessionHandler,
MessageHandler,
ModelHandler,
ProviderHandler,
ConnectorHandler,
PermissionHandler,
FileSystemHandler,
CommandHandler,
SkillHandler,
EventHandler,
QuestionHandler,
ReferenceHandler,
).pipe(
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(SessionV2.defaultLayer),
Layer.provide(SessionExecutionLocal.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)

View File

@@ -0,0 +1,15 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) =>
handlers.handle("agent.list", () =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((plugin) => plugin.wait())
return yield* response(AgentV2.Service.use((agent) => agent.all()))
}),
),
)

View File

@@ -0,0 +1,9 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const CommandHandler = HttpApiBuilder.group(Api, "server.command", (handlers) =>
handlers.handle("command.list", () => response(CommandV2.Service.use((command) => command.list()))),
)

View File

@@ -0,0 +1,103 @@
import { Connector } from "@opencode-ai/core/connector"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { InvalidRequestError } from "../errors"
import { response } from "../groups/location"
const authorize = <A, R>(effect: Effect.Effect<A, Connector.AuthorizationError, R>) =>
effect.pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Authentication failed",
kind: "connector_authorization",
}),
),
)
export const ConnectorHandler = HttpApiBuilder.group(Api, "server.connector", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"connector.list",
Effect.fn(function* () {
const service = yield* Connector.Service
return yield* response(service.list())
}),
)
.handle(
"connector.get",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
return yield* response(service.get(ctx.params.connectorID))
}),
)
.handle(
"connector.connect.key",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
yield* authorize(
service.connect.key({
connectorID: ctx.params.connectorID,
methodID: ctx.payload.methodID,
key: ctx.payload.key,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"connector.connect.oauth.begin",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
return yield* response(
authorize(
service.connect.oauth.begin({
connectorID: ctx.params.connectorID,
methodID: ctx.payload.methodID,
inputs: ctx.payload.inputs,
label: ctx.payload.label,
}),
),
)
}),
)
.handle(
"connector.connect.oauth.status",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
return yield* response(service.connect.oauth.status(ctx.params.attemptID))
}),
)
.handle(
"connector.connect.oauth.complete",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
yield* service.connect.oauth.complete({ attemptID: ctx.params.attemptID, code: ctx.payload.code }).pipe(
Effect.mapError(
(error) =>
new InvalidRequestError({
message:
error._tag === "Connector.CodeRequired"
? "Authorization code is required"
: "Authentication failed",
kind: error._tag === "Connector.CodeRequired" ? "connector_code_required" : "connector_authorization",
}),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"connector.connect.oauth.cancel",
Effect.fn(function* (ctx) {
const service = yield* Connector.Service
yield* service.connect.oauth.cancel(ctx.params.attemptID)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View File

@@ -0,0 +1,63 @@
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Effect, Stream } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { Api } from "../api"
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) =>
Effect.gen(function* () {
const events = yield* EventV2.Service
return handlers.handleRaw("event.subscribe", () =>
Effect.gen(function* () {
const location = yield* Location.Service
const connected = {
id: EventV2.ID.create(),
type: "server.connected",
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: {},
}
return HttpServerResponse.stream(
Stream.make(connected).pipe(
Stream.concat(
events
.all()
.pipe(
Stream.filter(
(event) =>
event.location?.directory === location.directory &&
event.location.workspaceID === location.workspaceID,
),
),
),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}),
)
}),
)

View File

@@ -0,0 +1,39 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { RelativePath } from "@opencode-ai/core/schema"
import { Effect } from "effect"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const FileSystemHandler = HttpApiBuilder.group(Api, "server.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handleRaw("fs.read", (ctx) =>
Effect.gen(function* () {
const file = yield* (yield* FileSystem.Service).read({
path: RelativePath.make(
decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13)),
),
})
return HttpServerResponse.uint8Array(file.content, { contentType: file.mime })
}),
)
.handle("fs.list", (ctx) =>
response(
Effect.gen(function* () {
const fs = yield* FileSystem.Service
return yield* fs.list(ctx.query)
}),
),
)
.handle("fs.find", (ctx) =>
response(
Effect.gen(function* () {
const fs = yield* FileSystem.Service
return yield* fs.find(ctx.query)
}),
),
)
}),
)

View File

@@ -0,0 +1,7 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
handlers.handle("health.get", () => Effect.succeed({ healthy: true as const })),
)

View File

@@ -0,0 +1,18 @@
import { Location } from "@opencode-ai/core/location"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
handlers.handle(
"location.get",
Effect.fn(function* () {
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
})
}),
),
)

View File

@@ -0,0 +1,84 @@
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../errors"
const DefaultMessagesLimit = 50
const Cursor = Schema.Struct({
id: SessionMessage.ID,
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
})
const decodeCursor = Schema.decodeUnknownSync(Cursor)
const cursor = {
encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") {
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
},
decode(input: string) {
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
},
}
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
return handlers.handle(
"session.messages",
Effect.fn(function* (ctx) {
if (ctx.query.cursor && ctx.query.order !== undefined)
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
const decoded = yield* Effect.try({
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
})
const order = decoded?.order ?? ctx.query.order ?? "desc"
const messages = yield* session
.messages({
sessionID: ctx.params.sessionID,
limit: ctx.query.limit ?? DefaultMessagesLimit,
order,
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
const first = messages[0]
const last = messages.at(-1)
return {
data: messages,
cursor: {
previous: first ? cursor.encode(first, order, "previous") : undefined,
next: last ? cursor.encode(last, order, "next") : undefined,
},
}
}),
)
}),
)

View File

@@ -0,0 +1,26 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ServiceUnavailableError } from "../errors"
import { response } from "../groups/location"
const catalogUnavailable = new ServiceUnavailableError({
message: "Model catalog is unavailable",
service: "catalog",
})
export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"model.list",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* response(catalog.model.available())
}),
)
}),
)

View File

@@ -0,0 +1,61 @@
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "../errors"
import { response } from "../groups/location"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"permission.request.list",
Effect.fn(function* () {
return yield* response((yield* PermissionV2.Service).list())
}),
)
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const permission = yield* PermissionV2.Service
return { data: yield* permission.forSession(ctx.params.sessionID) }
}),
)
.handle(
"session.permission.reply",
Effect.fn(function* (ctx) {
const permission = yield* PermissionV2.Service
const request = yield* permission.get(ctx.params.requestID)
if (!request || request.sessionID !== ctx.params.sessionID) return yield* missingRequest(ctx.params.requestID)
yield* permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {
const location = yield* Location.Service
return {
data: yield* (yield* PermissionSaved.Service).list({
projectID: ctx.query.projectID ?? location.project.id,
}),
}
}),
)
.handle(
"permission.saved.remove",
Effect.fn(function* (ctx) {
yield* (yield* PermissionSaved.Service).remove(ctx.params.id)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View File

@@ -0,0 +1,46 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ProviderNotFoundError, ServiceUnavailableError } from "../errors"
import { response } from "../groups/location"
const catalogUnavailable = new ServiceUnavailableError({
message: "Provider catalog is unavailable",
service: "catalog",
})
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
Effect.gen(function* () {
return handlers
.handle(
"provider.list",
Effect.fn(function* () {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* response(catalog.provider.available())
}),
)
.handle(
"provider.get",
Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* response(catalog.provider.get(ctx.params.providerID)).pipe(
Effect.catchTag("CatalogV2.ProviderNotFound", (error) =>
Effect.fail(
new ProviderNotFoundError({
providerID: error.providerID,
message: `Provider not found: ${error.providerID}`,
}),
),
),
)
}),
)
}),
)

View File

@@ -0,0 +1,62 @@
import { QuestionV2 } from "@opencode-ai/core/question"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { QuestionNotFoundError } from "../errors"
import { response } from "../groups/location"
function missingRequest(id: QuestionV2.ID) {
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
}
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
Effect.gen(function* () {
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
sessionID: QuestionV2.Request["sessionID"],
requestID: QuestionV2.ID,
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
) {
const question = yield* QuestionV2.Service
const request = (yield* question.list()).find((request) => request.id === requestID)
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
return yield* use(question)
})
return handlers
.handle(
"question.request.list",
Effect.fn(function* () {
return yield* response((yield* QuestionV2.Service).list())
}),
)
.handle(
"session.question.list",
Effect.fn(function* (ctx) {
const requests = yield* (yield* QuestionV2.Service).list()
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
}),
)
.handle(
"session.question.reply",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.question.reject",
Effect.fn(function* (ctx) {
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
question
.reject(ctx.params.requestID)
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View File

@@ -0,0 +1,8 @@
import { Reference } from "@opencode-ai/core/reference"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const ReferenceHandler = HttpApiBuilder.group(Api, "server.reference", (handlers) =>
handlers.handle("reference.list", () => response(Reference.Service.use((reference) => reference.list()))),
)

View File

@@ -0,0 +1,208 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { DateTime, Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "../groups/session"
import {
ConflictError,
InvalidCursorError,
ServiceUnavailableError,
SessionNotFoundError,
UnknownError,
} from "../errors"
import { AbsolutePath } from "@opencode-ai/core/schema"
const DefaultSessionsLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
return handlers
.handle(
"session.list",
Effect.fn(function* (ctx) {
const query =
ctx.query.cursor !== undefined
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
)
: ctx.query
const sessions = yield* session.list({
...query,
workspaceID: query.workspace,
limit: ctx.query.limit ?? DefaultSessionsLimit,
})
const first = sessions[0]
const last = sessions.at(-1)
return {
data: sessions,
cursor: {
previous: first
? SessionsCursor.make({
...query,
anchor: {
id: first.id,
time: DateTime.toEpochMillis(first.time.created),
direction: "previous",
},
})
: undefined,
next: last
? SessionsCursor.make({
...query,
anchor: {
id: last.id,
time: DateTime.toEpochMillis(last.time.created),
direction: "next",
},
})
: undefined,
},
}
}),
)
.handle(
"session.create",
Effect.fn(function* (ctx) {
return {
data: yield* session.create({
id: ctx.payload.id,
agent: ctx.payload.agent,
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
}),
}
}),
)
.handle(
"session.get",
Effect.fn(function* (ctx) {
return {
data: yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
}
}),
)
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
return {
data: yield* session
.prompt({
sessionID: ctx.params.sessionID,
id: ctx.payload.id,
prompt: ctx.payload.prompt,
delivery: ctx.payload.delivery,
resume: ctx.payload.resume,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.PromptConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
resource: error.messageID,
}),
),
),
),
}
}),
)
.handle(
"session.compact",
Effect.fn(function* (ctx) {
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `Session ${error.operation} is not available yet`,
service: `session.${error.operation}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.wait",
Effect.fn(function* (ctx) {
yield* session.wait(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.OperationUnavailableError", (error) =>
Effect.fail(
new ServiceUnavailableError({
message: `Session ${error.operation} is not available yet`,
service: `session.${error.operation}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.context",
Effect.fn(function* (ctx) {
return {
data: yield* session.context(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.MessageDecodeError", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
}),
)

View File

@@ -0,0 +1,8 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { response } from "../groups/location"
export const SkillHandler = HttpApiBuilder.group(Api, "server.skill", (handlers) =>
handlers.handle("skill.list", () => response(SkillV2.Service.use((skill) => skill.list()))),
)

View File

@@ -0,0 +1,57 @@
import { ServerAuth } from "../auth"
import { UnauthorizedError } from "../errors"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
const AUTH_TOKEN_QUERY = "auth_token"
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
error: UnauthorizedError,
}) {}
function emptyCredential() {
return { username: "", password: Redacted.make("") }
}
function decodeCredential(input: string) {
return Effect.fromResult(Encoding.decodeBase64String(input)).pipe(
Effect.match({
onFailure: emptyCredential,
onSuccess: (header) => {
const separator = header.indexOf(":")
if (separator === -1) return emptyCredential()
return { username: header.slice(0, separator), password: Redacted.make(header.slice(separator + 1)) }
},
}),
)
}
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
const url = new URL(request.url, "http://localhost")
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
if (token) return decodeCredential(token)
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) return decodeCredential(match[1])
return Effect.succeed(emptyCredential())
}
export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const credential = yield* credentialFromRequest(request)
if (ServerAuth.authorized(credential, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
}),
)
}),
)

View File

@@ -0,0 +1,23 @@
import { Effect } from "effect"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError } from "../errors"
const REASON_LIMIT = 1024
function truncateReason(reason: string) {
if (reason.length <= REASON_LIMIT) return reason
return reason.slice(0, REASON_LIMIT) + `... (${reason.length - REASON_LIMIT} more chars)`
}
export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
"@opencode/HttpApiSchemaError",
{ error: InvalidRequestError },
) {}
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
const reason = truncateReason(error.cause.message)
return Effect.logWarning("schema rejection").pipe(
Effect.annotateLogs({ kind: error.kind, reason }),
Effect.andThen(Effect.fail(new InvalidRequestError({ message: reason, kind: error.kind }))),
)
})

View File

@@ -0,0 +1,69 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "../errors"
import type { LocationServices } from "../groups/location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
{
provides: LocationServices
}
>()("@opencode/HttpApiSessionLocation", {
error: [InvalidRequestError, SessionNotFoundError],
}) {}
const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
export const sessionLocationLayer = Layer.effect(
SessionLocationMiddleware,
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
return SessionLocationMiddleware.of((effect) =>
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
Effect.mapError(
() =>
new InvalidRequestError({
message: "Invalid session ID",
field: "sessionID",
}),
),
)
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* effect.pipe(
Effect.provide(
locations.get(
Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
}),
),
),
)
}),
)
}),
)

View File

@@ -0,0 +1,33 @@
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Layer, Option } from "effect"
import { Api } from "./api"
import { ServerAuth } from "./auth"
import { handlers } from "./handlers"
import { authorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
export function createRoutes(password?: string) {
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers),
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(
password
? ServerAuth.Config.layer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.defaultLayer,
),
Layer.provide(LocationServiceMap.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
}
export const routes = createRoutes()
export const webHandler = () =>
HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)), { disableLogger: true })

10
packages/server/sst-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}