refactor(httpapi): split groups from handlers

This commit is contained in:
Kit Langton
2026-04-28 20:27:07 -04:00
parent 41367fa3ba
commit 35239bddef
50 changed files with 2098 additions and 2003 deletions
@@ -0,0 +1,45 @@
import { HttpApi } from "effect/unstable/httpapi"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
import { GlobalApi } from "./groups/global"
import { InstanceApi } from "./groups/instance"
import { McpApi } from "./groups/mcp"
import { PermissionApi } from "./groups/permission"
import { ProjectApi } from "./groups/project"
import { ProviderApi } from "./groups/provider"
import { PtyApi, PtyConnectApi } from "./groups/pty"
import { QuestionApi } from "./groups/question"
import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace"
export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi)
export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(ProjectApi)
.addHttpApi(PtyApi)
.addHttpApi(QuestionApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProviderApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(RootHttpApi)
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
export type RootHttpApiType = typeof RootHttpApi
export type InstanceHttpApiType = typeof InstanceHttpApi
@@ -1,252 +0,0 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
const log = Log.create({ service: "server" })
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
}).annotate({ identifier: "GlobalHealth" })
const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Unknown,
}).annotate({ identifier: "GlobalEvent" })
const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
}).annotate({ identifier: "GlobalUpgradeInput" })
const GlobalUpgradeResult = Schema.Union([
Schema.Struct({
success: Schema.Literal(true),
version: Schema.String,
}),
Schema.Struct({
success: Schema.Literal(false),
error: Schema.String,
}),
]).annotate({ identifier: "GlobalUpgradeResult" })
export const GlobalPaths = {
health: "/global/health",
event: "/global/event",
config: "/global/config",
dispose: "/global/dispose",
upgrade: "/global/upgrade",
} as const
export const GlobalApi = HttpApi.make("global").add(
HttpApiGroup.make("global")
.add(
HttpApiEndpoint.get("health", GlobalPaths.health, {
success: GlobalHealth,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.health",
summary: "Get health",
description: "Get health information about the OpenCode server.",
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
summary: "Get global events",
description: "Subscribe to global events from the OpenCode system using server-sent events.",
}),
),
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.get",
summary: "Get global configuration",
description: "Retrieve the current global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
payload: Config.Info,
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.update",
summary: "Update global configuration",
description: "Update global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.dispose",
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
}),
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
success: GlobalUpgradeResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.upgrade",
summary: "Upgrade opencode",
description: "Upgrade opencode to the specified version or latest if not specified.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
)
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
function eventResponse() {
log.info("global event connected")
const events = Stream.callback<GlobalBusEvent>((queue) => {
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
return Effect.acquireRelease(
Effect.sync(() => GlobalBus.on("event", handler)),
() => Effect.sync(() => GlobalBus.off("event", handler)),
)
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}
export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) =>
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
})
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
return yield* config.updateGlobal(ctx.payload)
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
})
return true
})
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
const method = yield* installation.method()
if (method === "unknown") {
return {
status: 400,
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Effect.succeed({
status: 500,
body: {
success: false as const,
error: err instanceof Error ? err.message : String(err),
},
}),
),
)
if (!result.body.success) return result
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return result
})
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload })
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handle("health", health)
.handleRaw("event", event)
.handle("configGet", configGet)
.handle("configUpdate", configUpdate)
.handle("dispose", dispose)
.handleRaw("upgrade", upgradeRaw)
}),
)
@@ -1,10 +1,8 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/config"
@@ -47,6 +45,7 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi config routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -56,30 +55,3 @@ export const ConfigApi = HttpApi.make("config")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
yield* configSvc.update(ctx.payload, { dispose: false })
yield* markInstanceForDisposal(yield* InstanceState.context)
return ctx.payload
})
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
)
@@ -1,8 +1,7 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const AuthParams = Schema.Struct({
providerID: ProviderID,
@@ -13,7 +12,7 @@ const LogQuery = Schema.Struct({
workspace: Schema.optional(Schema.String),
})
const LogInput = Schema.Struct({
export const LogInput = Schema.Struct({
service: Schema.String.annotate({ description: "Service name for the log entry" }),
level: Schema.Union([
Schema.Literal("debug"),
@@ -70,30 +69,3 @@ export const ControlApi = HttpApi.make("control").add(
)
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
)
export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service })
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
return true
})
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
}),
)
@@ -1,24 +1,17 @@
import { Account } from "@/account/account"
import { AccountID, OrgID } from "@/account/schema"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema, SchemaGetter } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optionalKey(Schema.String),
switchableOrgCount: Schema.Number,
switchableOrgCount: Schema.Finite,
}).annotate({ identifier: "ConsoleState" })
const ConsoleOrgOption = Schema.Struct({
@@ -34,7 +27,7 @@ const ConsoleOrgList = Schema.Struct({
orgs: Schema.Array(ConsoleOrgOption),
}).annotate({ identifier: "ConsoleOrgList" })
const ConsoleSwitchPayload = Schema.Struct({
export const ConsoleSwitchPayload = Schema.Struct({
accountID: AccountID,
orgID: OrgID,
}).annotate({ identifier: "ConsoleSwitchInput" })
@@ -46,7 +39,7 @@ const ToolListItem = Schema.Struct({
parameters: Schema.Record(Schema.String, Schema.Any),
}).annotate({ identifier: "ToolListItem" })
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
const ToolListQuery = Schema.Struct({
export const ToolListQuery = Schema.Struct({
provider: ProviderID,
model: ModelID,
})
@@ -58,7 +51,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
}),
)
const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
const SessionListQuery = Schema.Struct({
export const SessionListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
@@ -200,6 +193,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi read-only routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -209,143 +203,3 @@ export const ExperimentalApi = HttpApi.make("experimental")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
orgs: groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
),
}
})
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
payload: typeof ConsoleSwitchPayload.Type
}) {
yield* account
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
}))
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource)
}),
)
@@ -1,20 +1,20 @@
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { LSP } from "@/lsp/lsp"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const FileQuery = Schema.Struct({
export const FileQuery = Schema.Struct({
path: Schema.String,
})
const FindTextQuery = Schema.Struct({
export const FindTextQuery = Schema.Struct({
pattern: Schema.String,
})
const FindFileQuery = Schema.Struct({
export const FindFileQuery = Schema.Struct({
query: Schema.String,
dirs: Schema.optional(Schema.Literals(["true", "false"])),
type: Schema.optional(Schema.Literals(["file", "directory"])),
@@ -23,7 +23,7 @@ const FindFileQuery = Schema.Struct({
),
})
const FindSymbolQuery = Schema.Struct({
export const FindSymbolQuery = Schema.Struct({
query: Schema.String,
})
@@ -106,6 +106,7 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi file routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -115,51 +116,3 @@ export const FileApi = HttpApi.make("file")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(Effect.orDie)).items
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
return []
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status)
}),
)
@@ -0,0 +1,101 @@
import { Config } from "@/config/config"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
}).annotate({ identifier: "GlobalHealth" })
const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Unknown,
}).annotate({ identifier: "GlobalEvent" })
export const GlobalUpgradeInput = Schema.Struct({
target: Schema.optional(Schema.String),
}).annotate({ identifier: "GlobalUpgradeInput" })
const GlobalUpgradeResult = Schema.Union([
Schema.Struct({
success: Schema.Literal(true),
version: Schema.String,
}),
Schema.Struct({
success: Schema.Literal(false),
error: Schema.String,
}),
]).annotate({ identifier: "GlobalUpgradeResult" })
export const GlobalPaths = {
health: "/global/health",
event: "/global/event",
config: "/global/config",
dispose: "/global/dispose",
upgrade: "/global/upgrade",
} as const
export const GlobalApi = HttpApi.make("global").add(
HttpApiGroup.make("global")
.add(
HttpApiEndpoint.get("health", GlobalPaths.health, {
success: GlobalHealth,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.health",
summary: "Get health",
description: "Get health information about the OpenCode server.",
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
summary: "Get global events",
description: "Subscribe to global events from the OpenCode system using server-sent events.",
}),
),
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.get",
summary: "Get global configuration",
description: "Retrieve the current global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
payload: Config.Info,
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.config.update",
summary: "Update global configuration",
description: "Update global OpenCode configuration settings and preferences.",
}),
),
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.dispose",
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
}),
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
success: GlobalUpgradeResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.upgrade",
summary: "Upgrade opencode",
description: "Upgrade opencode to the specified version or latest if not specified.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
)
@@ -1,15 +1,13 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const PathInfo = Schema.Struct({
home: Schema.String,
@@ -19,7 +17,7 @@ const PathInfo = Schema.Struct({
directory: Schema.String,
}).annotate({ identifier: "Path" })
const VcsDiffQuery = Schema.Struct({
export const VcsDiffQuery = Schema.Struct({
mode: Vcs.Mode,
})
@@ -130,6 +128,7 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi instance read routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -139,70 +138,3 @@ export const InstanceApi = HttpApi.make("instance")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const skill = yield* Skill.Service
const vcs = yield* Vcs.Service
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
yield* markInstanceForDisposal(yield* InstanceState.context)
return true
})
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
const ctx = yield* InstanceState.context
return {
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: ctx.worktree,
directory: ctx.directory,
}
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
return yield* agent.list()
})
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
return yield* skill.all()
})
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
return yield* lsp.status()
})
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
return yield* format.status()
})
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter)
}),
)
@@ -1,26 +1,27 @@
import { MCP } from "@/mcp"
import { ConfigMCP } from "@/config/mcp"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const AddPayload = Schema.Struct({
export const AddPayload = Schema.Struct({
name: Schema.String,
config: ConfigMCP.Info,
}).annotate({ identifier: "McpAddInput" })
const StatusMap = Schema.Record(Schema.String, MCP.Status)
const AuthStartResponse = Schema.Struct({
export const StatusMap = Schema.Record(Schema.String, MCP.Status)
export const AuthStartResponse = Schema.Struct({
authorizationUrl: Schema.String,
oauthState: Schema.String,
}).annotate({ identifier: "McpAuthStartResponse" })
const AuthCallbackPayload = Schema.Struct({
export const AuthCallbackPayload = Schema.Struct({
code: Schema.String,
}).annotate({ identifier: "McpAuthCallbackInput" })
const AuthRemoveResponse = Schema.Struct({
export const AuthRemoveResponse = Schema.Struct({
success: Schema.Literal(true),
}).annotate({ identifier: "McpAuthRemoveResponse" })
class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
export class UnsupportedOAuthError extends Schema.ErrorClass<UnsupportedOAuthError>("McpUnsupportedOAuthError")(
{ error: Schema.String },
{ httpApiStatus: 400 },
) {}
@@ -127,6 +128,7 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi MCP routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -136,66 +138,3 @@ export const McpApi = HttpApi.make("mcp")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
return yield* Schema.decodeUnknownEffect(StatusMap)(
"status" in result ? { [ctx.payload.name]: result } : result,
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.startAuth(ctx.params.name)
})
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
params: { name: string }
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.authenticate(ctx.params.name)
})
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
yield* mcp.removeAuth(ctx.params.name)
return { success: true as const }
})
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
yield* mcp.disconnect(ctx.params.name)
return true
})
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect)
}),
)
@@ -1,8 +1,9 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/permission"
@@ -37,6 +38,7 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi permission routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -46,27 +48,3 @@ export const PermissionApi = HttpApi.make("permission")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
)
@@ -1,12 +1,9 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
import { Project } from "@/project/project"
import { InstanceBootstrap } from "@/project/bootstrap"
import { ProjectID } from "@/project/schema"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForReload } from "./lifecycle"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/project"
@@ -59,6 +56,7 @@ export const ProjectApi = HttpApi.make("project")
description: "Experimental HttpApi project routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -68,40 +66,3 @@ export const ProjectApi = HttpApi.make("project")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return (yield* InstanceState.context).project
})
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
const ctx = yield* InstanceState.context
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
return next
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
)
@@ -0,0 +1,73 @@
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: Provider.ListResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: ProviderAuth.Methods,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: Schema.UndefinedOr(ProviderAuth.Authorization),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -0,0 +1,111 @@
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/pty"
export const Params = Schema.Struct({ ptyID: PtyID })
export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
export const ShellItem = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
})
export const PtyPaths = {
shells: `${root}/shells`,
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, { success: Schema.Array(ShellItem) }).annotateMerge(
OpenApi.annotations({
identifier: "pty.shells",
summary: "List available shells",
description: "Get a list of available shells on the system.",
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, { success: Schema.Array(Pty.Info) }).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, { payload: Pty.CreateInput, success: Pty.Info }).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: Schema.Boolean }).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
@@ -1,8 +1,9 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/question"
@@ -47,6 +48,7 @@ export const QuestionApi = HttpApi.make("question")
description: "Question routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -56,31 +58,3 @@ export const QuestionApi = HttpApi.make("question")
description: "Effect HttpApi surface for instance routes.",
}),
)
export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
)
@@ -0,0 +1,415 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { Schema, SchemaGetter, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
export const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
})
export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
export const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Schema.Finite),
}),
),
}).annotate({ identifier: "SessionUpdateInput" })
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
identifier: "SessionForkInput",
})
export const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
messageID: MessageID,
}).annotate({ identifier: "SessionInitInput" })
export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "SessionSummarizeInput" })
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
identifier: "SessionPromptInput",
})
export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
identifier: "SessionCommandInput",
})
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
identifier: "SessionShellInput",
})
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
identifier: "SessionRevertInput",
})
export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
}).annotate({ identifier: "SessionPermissionResponseInput" })
export const SessionPaths = {
list: root,
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
create: root,
remove: `${root}/:sessionID`,
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
} as const
export const SessionApi = HttpApi.make("session")
.add(
HttpApiGroup.make("session")
.add(
HttpApiEndpoint.get("list", SessionPaths.list, {
query: ListQuery,
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.list",
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
success: StatusMap,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.status",
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
}),
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: Session.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
}),
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
summary: "Get session children",
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: Schema.Array(Todo.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,
success: Schema.Array(Snapshot.FileDiff),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
}),
),
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: Schema.Array(MessageV2.WithParts),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
}),
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: MessageV2.WithParts,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: Session.Info,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
}),
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
}),
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
payload: UpdatePayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
}),
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
payload: ForkPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
}),
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
payload: InitPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
}),
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
}),
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
}),
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
payload: SummarizePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
}),
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
}),
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
payload: CommandPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
}),
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
payload: RevertPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
summary: "Revert message",
description:
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
}),
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
summary: "Respond to permission",
description: "Approve or deny a permission request from the AI assistant.",
deprecated: true,
}),
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
summary: "Delete message",
description:
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
}),
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
description: "Delete a part from a message.",
}),
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: MessageV2.Part,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",
description: "Update a part in a message.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
description: "Experimental HttpApi session routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
@@ -1,39 +1,29 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { NonNegativeInt } from "@/util/schema"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/sync"
const ReplayEvent = Schema.Struct({
export const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncReplayEvent" })
const ReplayPayload = Schema.Struct({
export const ReplayPayload = Schema.Struct({
directory: Schema.String,
events: Schema.NonEmptyArray(ReplayEvent),
}).annotate({ identifier: "SyncReplayInput" })
const ReplayResponse = Schema.Struct({
export const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
}).annotate({ identifier: "SyncReplayResponse" })
const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
const HistoryEvent = Schema.Struct({
export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
export const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
seq: Schema.Number,
seq: Schema.Finite,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncHistoryEvent" })
@@ -87,6 +77,7 @@ export const SyncApi = HttpApi.make("sync")
description: "Experimental HttpApi sync routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -97,41 +88,3 @@ export const SyncApi = HttpApi.make("sync")
}),
)
export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
@@ -1,43 +1,22 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../tui"
import { Authorization } from "./auth"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/tui"
const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
const TuiRequestPayload = Schema.Struct({
export const CommandPayload = Schema.Struct({ command: Schema.String }).annotate({ identifier: "TuiCommandInput" })
export const TuiRequestPayload = Schema.Struct({
path: Schema.String,
body: Schema.Unknown,
}).annotate({ identifier: "TuiRequest" })
const TuiPublishPayload = Schema.Union([
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }),
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }),
export const TuiPublishPayload = Schema.Union([
Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties }).annotate({ identifier: "EventTuiPromptAppend" }),
Schema.Struct({ type: Schema.Literal(TuiEvent.CommandExecute.type), properties: TuiEvent.CommandExecute.properties }).annotate({ identifier: "EventTuiCommandExecute" }),
Schema.Struct({ type: Schema.Literal(TuiEvent.ToastShow.type), properties: TuiEvent.ToastShow.properties }).annotate({ identifier: "EventTuiToastShow" }),
Schema.Struct({ type: Schema.Literal(TuiEvent.SessionSelect.type), properties: TuiEvent.SessionSelect.properties }).annotate({ identifier: "EventTuiSessionSelect" }),
]).annotate({ identifier: "TuiEventInput" })
const commandAliases = {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
} as const
export const TuiPaths = {
appendPrompt: `${root}/append-prompt`,
openHelp: `${root}/open-help`,
@@ -173,6 +152,7 @@ export const TuiApi = HttpApi.make("tui")
),
)
.annotateMerge(OpenApi.annotations({ title: "tui", description: "Experimental HttpApi TUI routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -183,110 +163,3 @@ export const TuiApi = HttpApi.make("tui")
}),
)
export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse)
}),
)
@@ -1,23 +1,21 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect, Schema, Struct } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../auth"
import { InstanceContextMiddleware } from "../instance-context"
const root = "/experimental/workspace"
const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])).annotate({
identifier: "WorkspaceCreateInput",
})
const SessionRestorePayload = Schema.Struct(
export const SessionRestorePayload = Schema.Struct(
Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]),
).annotate({
identifier: "WorkspaceSessionRestoreInput",
})
const SessionRestoreResponse = Schema.Struct({
total: Schema.Number,
export const SessionRestoreResponse = Schema.Struct({
total: Schema.Finite,
}).annotate({ identifier: "WorkspaceSessionRestoreResponse" })
export const WorkspacePaths = {
@@ -41,9 +39,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "List all available workspace adaptors for the current project.",
}),
),
HttpApiEndpoint.get("list", WorkspacePaths.list, {
success: Schema.Array(Workspace.Info),
}).annotateMerge(
HttpApiEndpoint.get("list", WorkspacePaths.list, { success: Schema.Array(Workspace.Info) }).annotateMerge(
OpenApi.annotations({
identifier: "experimental.workspace.list",
summary: "List workspaces",
@@ -91,12 +87,8 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "workspace",
description: "Experimental HttpApi workspace routes.",
}),
)
.annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." }))
.middleware(InstanceContextMiddleware)
.middleware(Authorization),
)
.annotateMerge(
@@ -106,61 +98,3 @@ export const WorkspaceApi = HttpApi.make("workspace")
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const ctx = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(ctx.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)
@@ -0,0 +1,34 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
const get = Effect.fn("ConfigHttpApi.get")(function* () {
return yield* configSvc.get()
})
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
yield* configSvc.update(ctx.payload, { dispose: false })
yield* markInstanceForDisposal(yield* InstanceState.context)
return ctx.payload
})
const providers = Effect.fn("ConfigHttpApi.providers")(function* () {
const providers = yield* providerSvc.list()
return {
providers: Object.values(providers),
default: Provider.defaultModelIDs(providers),
}
})
return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
)
@@ -0,0 +1,34 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service })
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
return true
})
return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
}),
)
@@ -0,0 +1,155 @@
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { MCP } from "@/mcp"
import { Project } from "@/project/project"
import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
import { Effect, Option } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery } from "../groups/experimental"
export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
const config = yield* Config.Service
const mcp = yield* MCP.Service
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
[config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
return {
consoleManagedProviders: state.consoleManagedProviders,
...(state.activeOrgName ? { activeOrgName: state.activeOrgName } : {}),
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
})
const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () {
const [groups, active] = yield* Effect.all(
[account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)],
{
concurrency: "unbounded",
},
)
const info = Option.getOrUndefined(active)
return {
orgs: groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
),
}
})
const switchConsole = Effect.fn("ExperimentalHttpApi.consoleSwitch")(function* (ctx: {
payload: typeof ConsoleSwitchPayload.Type
}) {
yield* account
.use(ctx.payload.accountID, Option.some(ctx.payload.orgID))
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
const tool = Effect.fn("ExperimentalHttpApi.tool")(function* (ctx: { query: typeof ToolListQuery.Type }) {
const list = yield* registry.tools({
providerID: ctx.query.provider,
modelID: ctx.query.model,
agent: yield* agents.get(yield* agents.defaultAgent()),
})
return list.map((item) => ({
id: item.id,
description: item.description,
parameters: EffectZod.toJsonSchema(item.parameters),
}))
})
const toolIDs = Effect.fn("ExperimentalHttpApi.toolIDs")(function* () {
return yield* registry.ids()
})
const worktree = Effect.fn("ExperimentalHttpApi.worktree")(function* () {
const ctx = yield* InstanceState.context
return yield* project.sandboxes(ctx.project.id)
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
}) {
return yield* worktreeSvc.create(ctx.payload)
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
payload: Worktree.RemoveInput
}) {
const ctx = yield* InstanceState.context
yield* worktreeSvc.remove(input.payload)
yield* project.removeSandbox(ctx.project.id, input.payload.directory)
return true
})
const worktreeReset = Effect.fn("ExperimentalHttpApi.worktreeReset")(function* (ctx: {
payload: Worktree.ResetInput
}) {
yield* worktreeSvc.reset(ctx.payload)
return true
})
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
return yield* mcp.resources()
})
return handlers
.handle("console", getConsole)
.handle("consoleOrgs", listConsoleOrgs)
.handle("consoleSwitch", switchConsole)
.handle("tool", tool)
.handle("toolIDs", toolIDs)
.handle("worktree", worktree)
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource)
}),
)
@@ -0,0 +1,54 @@
import * as InstanceState from "@/effect/instance-state"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) {
return (yield* ripgrep
.search({ cwd: (yield* InstanceState.context).directory, pattern: ctx.query.pattern, limit: 10 })
.pipe(Effect.orDie)).items
})
const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: {
query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number }
}) {
return yield* svc.search({
query: ctx.query.query,
limit: ctx.query.limit ?? 10,
dirs: ctx.query.dirs !== "false",
type: ctx.query.type,
})
})
const findSymbol = Effect.fn("FileHttpApi.findSymbol")(function* () {
return []
})
const list = Effect.fn("FileHttpApi.list")(function* (ctx: { query: { path: string } }) {
return yield* svc.list(ctx.query.path)
})
const content = Effect.fn("FileHttpApi.content")(function* (ctx: { query: { path: string } }) {
return yield* svc.read(ctx.query.path)
})
const status = Effect.fn("FileHttpApi.status")(function* () {
return yield* svc.status()
})
return handlers
.handle("findText", findText)
.handle("findFile", findFile)
.handle("findSymbol", findSymbol)
.handle("list", list)
.handle("content", content)
.handle("status", status)
}),
)
@@ -0,0 +1,156 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation"
import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse"
import { RootHttpApi } from "../api"
import { GlobalUpgradeInput } from "../groups/global"
const log = Log.create({ service: "server" })
function eventData(data: unknown): Sse.Event {
return {
_tag: "Event",
event: "message",
id: undefined,
data: JSON.stringify(data),
}
}
function parseBody(body: string) {
try {
return JSON.parse(body || "{}") as unknown
} catch {
return undefined
}
}
function eventResponse() {
log.info("global event connected")
const events = Stream.callback<GlobalBusEvent>((queue) => {
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
return Effect.acquireRelease(
Effect.sync(() => GlobalBus.on("event", handler)),
() => Effect.sync(() => GlobalBus.off("event", handler)),
)
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
}
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) =>
Effect.gen(function* () {
const config = yield* Config.Service
const installation = yield* Installation.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion }
})
const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse()
})
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
return yield* config.getGlobal()
})
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
return yield* config.updateGlobal(ctx.payload)
})
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", {
directory: "global",
payload: { type: "global.disposed", properties: {} },
})
return true
})
const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
const method = yield* installation.method()
if (method === "unknown") {
return {
status: 400,
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Effect.succeed({
status: 500,
body: {
success: false as const,
error: err instanceof Error ? err.message : String(err),
},
}),
),
)
if (!result.body.success) return result
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return result
})
const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const json = parseBody(body)
if (json === undefined) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
Effect.map((payload) => ({ valid: true as const, payload })),
Effect.catch(() => Effect.succeed({ valid: false as const })),
)
if (!payload.valid) {
return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
}
const result = yield* upgrade({ payload: payload.payload })
return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
})
return handlers
.handle("health", health)
.handleRaw("event", event)
.handle("configGet", configGet)
.handle("configUpdate", configUpdate)
.handle("dispose", dispose)
.handleRaw("upgrade", upgradeRaw)
}),
)
@@ -0,0 +1,79 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import * as InstanceState from "@/effect/instance-state"
import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForDisposal } from "../lifecycle"
export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
const format = yield* Format.Service
const lsp = yield* LSP.Service
const skill = yield* Skill.Service
const vcs = yield* Vcs.Service
const dispose = Effect.fn("InstanceHttpApi.dispose")(function* () {
yield* markInstanceForDisposal(yield* InstanceState.context)
return true
})
const getPath = Effect.fn("InstanceHttpApi.path")(function* () {
const ctx = yield* InstanceState.context
return {
home: Global.Path.home,
state: Global.Path.state,
config: Global.Path.config,
worktree: ctx.worktree,
directory: ctx.directory,
}
})
const getVcs = Effect.fn("InstanceHttpApi.vcs")(function* () {
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
return { branch, default_branch }
})
const getVcsDiff = Effect.fn("InstanceHttpApi.vcsDiff")(function* (ctx: { query: { mode: Vcs.Mode } }) {
return yield* vcs.diff(ctx.query.mode)
})
const getCommand = Effect.fn("InstanceHttpApi.command")(function* () {
return yield* command.list()
})
const getAgent = Effect.fn("InstanceHttpApi.agent")(function* () {
return yield* agent.list()
})
const getSkill = Effect.fn("InstanceHttpApi.skill")(function* () {
return yield* skill.all()
})
const getLsp = Effect.fn("InstanceHttpApi.lsp")(function* () {
return yield* lsp.status()
})
const getFormatter = Effect.fn("InstanceHttpApi.formatter")(function* () {
return yield* format.status()
})
return handlers
.handle("dispose", dispose)
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter)
}),
)
@@ -0,0 +1,68 @@
import { MCP } from "@/mcp"
import { Effect, Schema } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp"
export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
const status = Effect.fn("McpHttpApi.status")(function* () {
return yield* mcp.status()
})
const add = Effect.fn("McpHttpApi.add")(function* (ctx: { payload: typeof AddPayload.Type }) {
const result = (yield* mcp.add(ctx.payload.name, ctx.payload.config)).status
return yield* Schema.decodeUnknownEffect(StatusMap)(
"status" in result ? { [ctx.payload.name]: result } : result,
).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
})
const authStart = Effect.fn("McpHttpApi.authStart")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.startAuth(ctx.params.name)
})
const authCallback = Effect.fn("McpHttpApi.authCallback")(function* (ctx: {
params: { name: string }
payload: typeof AuthCallbackPayload.Type
}) {
return yield* mcp.finishAuth(ctx.params.name, ctx.payload.code)
})
const authAuthenticate = Effect.fn("McpHttpApi.authAuthenticate")(function* (ctx: { params: { name: string } }) {
if (!(yield* mcp.supportsOAuth(ctx.params.name))) {
return yield* new UnsupportedOAuthError({ error: `MCP server ${ctx.params.name} does not support OAuth` })
}
return yield* mcp.authenticate(ctx.params.name)
})
const authRemove = Effect.fn("McpHttpApi.authRemove")(function* (ctx: { params: { name: string } }) {
yield* mcp.removeAuth(ctx.params.name)
return { success: true as const }
})
const connect = Effect.fn("McpHttpApi.connect")(function* (ctx: { params: { name: string } }) {
yield* mcp.connect(ctx.params.name)
return true
})
const disconnect = Effect.fn("McpHttpApi.disconnect")(function* (ctx: { params: { name: string } }) {
yield* mcp.disconnect(ctx.params.name)
return true
})
return handlers
.handle("status", status)
.handle("add", add)
.handle("authStart", authStart)
.handle("authCallback", authCallback)
.handle("authAuthenticate", authAuthenticate)
.handle("authRemove", authRemove)
.handle("connect", connect)
.handle("disconnect", disconnect)
}),
)
@@ -0,0 +1,29 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return handlers.handle("list", list).handle("reply", reply)
}),
)
@@ -0,0 +1,46 @@
import { AppRuntime } from "@/effect/app-runtime"
import * as InstanceState from "@/effect/instance-state"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { markInstanceForReload } from "../lifecycle"
export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
const list = Effect.fn("ProjectHttpApi.list")(function* () {
return yield* svc.list()
})
const current = Effect.fn("ProjectHttpApi.current")(function* () {
return (yield* InstanceState.context).project
})
const initGit = Effect.fn("ProjectHttpApi.initGit")(function* () {
const ctx = yield* InstanceState.context
const next = yield* svc.initGit({ directory: ctx.directory, project: ctx.project })
if (next.id === ctx.project.id && next.vcs === ctx.project.vcs && next.worktree === ctx.project.worktree)
return next
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.directory,
project: next,
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
return next
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
)
@@ -0,0 +1,89 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) filtered[key] = value
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
return yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
}),
)
@@ -0,0 +1,118 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)))
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -0,0 +1,33 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
export const questionHandlers = HttpApiBuilder.group(InstanceHttpApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
const reject = Effect.fn("QuestionHttpApi.reject")(function* (ctx: { params: { requestID: QuestionID } }) {
yield* svc.reject(ctx.params.requestID)
return true
})
return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
)
@@ -6,7 +6,6 @@ import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -18,84 +17,17 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import { NotFoundError } from "@/storage/storage"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Schema, SchemaGetter, Struct } from "effect"
import { Effect, Schema } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiError,
HttpApiGroup,
HttpApiSchema,
OpenApi,
} from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CommandPayload, DiffQuery, ForkPayload, InitPayload, ListQuery, MessagesQuery, PermissionResponsePayload, PromptPayload, RevertPayload, ShellPayload, SummarizePayload, UpdatePayload } from "../groups/session"
const log = Log.create({ service: "server" })
const root = "/session"
const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
}),
)
const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
scope: Schema.optional(Schema.Literals(["project"])),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
start: Schema.optional(Schema.NumberFromString),
search: Schema.optional(Schema.String),
limit: Schema.optional(Schema.NumberFromString),
})
const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
const MessagesQuery = Schema.Struct({
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
before: Schema.optional(Schema.String),
})
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
permission: Schema.optional(Permission.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Schema.Number),
}),
),
}).annotate({ identifier: "SessionUpdateInput" })
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
identifier: "SessionForkInput",
})
const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
messageID: MessageID,
}).annotate({ identifier: "SessionInitInput" })
const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
auto: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "SessionSummarizeInput" })
const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
identifier: "SessionPromptInput",
})
const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
identifier: "SessionCommandInput",
})
const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
identifier: "SessionShellInput",
})
const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
identifier: "SessionRevertInput",
})
const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
}).annotate({ identifier: "SessionPermissionResponseInput" })
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
self.pipe(
@@ -105,346 +37,7 @@ const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
),
)
export const SessionPaths = {
list: root,
status: `${root}/status`,
get: `${root}/:sessionID`,
children: `${root}/:sessionID/children`,
todo: `${root}/:sessionID/todo`,
diff: `${root}/:sessionID/diff`,
messages: `${root}/:sessionID/message`,
message: `${root}/:sessionID/message/:messageID`,
create: root,
remove: `${root}/:sessionID`,
update: `${root}/:sessionID`,
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
init: `${root}/:sessionID/init`,
summarize: `${root}/:sessionID/summarize`,
prompt: `${root}/:sessionID/message`,
promptAsync: `${root}/:sessionID/prompt_async`,
command: `${root}/:sessionID/command`,
shell: `${root}/:sessionID/shell`,
revert: `${root}/:sessionID/revert`,
unrevert: `${root}/:sessionID/unrevert`,
permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
} as const
export const SessionApi = HttpApi.make("session")
.add(
HttpApiGroup.make("session")
.add(
HttpApiEndpoint.get("list", SessionPaths.list, {
query: ListQuery,
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.list",
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
}),
),
HttpApiEndpoint.get("status", SessionPaths.status, {
success: StatusMap,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.status",
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
}),
),
HttpApiEndpoint.get("get", SessionPaths.get, {
params: { sessionID: SessionID },
success: Session.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.get",
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
}),
),
HttpApiEndpoint.get("children", SessionPaths.children, {
params: { sessionID: SessionID },
success: Schema.Array(Session.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.children",
summary: "Get session children",
description: "Retrieve all child sessions that were forked from the specified parent session.",
}),
),
HttpApiEndpoint.get("todo", SessionPaths.todo, {
params: { sessionID: SessionID },
success: Schema.Array(Todo.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.todo",
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
}),
),
HttpApiEndpoint.get("diff", SessionPaths.diff, {
params: { sessionID: SessionID },
query: DiffQuery,
success: Schema.Array(Snapshot.FileDiff),
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
}),
),
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: Schema.Array(MessageV2.WithParts),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.messages",
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
}),
),
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
success: MessageV2.WithParts,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.message",
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
}),
),
HttpApiEndpoint.post("create", SessionPaths.create, {
payload: [HttpApiSchema.NoContent, Session.CreateInput],
success: Session.Info,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.create",
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
}),
),
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.delete",
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
}),
),
HttpApiEndpoint.patch("update", SessionPaths.update, {
params: { sessionID: SessionID },
payload: UpdatePayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.update",
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
}),
),
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
payload: ForkPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
}),
),
HttpApiEndpoint.post("abort", SessionPaths.abort, {
params: { sessionID: SessionID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.abort",
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
HttpApiEndpoint.post("init", SessionPaths.init, {
params: { sessionID: SessionID },
payload: InitPayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.init",
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
}),
),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.share",
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
}),
),
HttpApiEndpoint.delete("unshare", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unshare",
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
}),
),
HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
params: { sessionID: SessionID },
payload: SummarizePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.summarize",
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
}),
),
HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt",
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
}),
),
HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
params: { sessionID: SessionID },
payload: PromptPayload,
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.prompt_async",
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
}),
),
HttpApiEndpoint.post("command", SessionPaths.command, {
params: { sessionID: SessionID },
payload: CommandPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.command",
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
}),
),
HttpApiEndpoint.post("shell", SessionPaths.shell, {
params: { sessionID: SessionID },
payload: ShellPayload,
success: MessageV2.WithParts,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.shell",
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
}),
),
HttpApiEndpoint.post("revert", SessionPaths.revert, {
params: { sessionID: SessionID },
payload: RevertPayload,
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.revert",
summary: "Revert message",
description:
"Revert a specific message in a session, undoing its effects and restoring the previous state.",
}),
),
HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
params: { sessionID: SessionID },
success: Session.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.unrevert",
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
payload: PermissionResponsePayload,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.respond",
summary: "Respond to permission",
description: "Approve or deny a permission request from the AI assistant.",
deprecated: true,
}),
),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.deleteMessage",
summary: "Delete message",
description:
"Permanently delete a specific message and all of its parts from a session without reverting file changes.",
}),
),
HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.delete",
description: "Delete a part from a message.",
}),
),
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
payload: MessageV2.Part,
success: MessageV2.Part,
}).annotateMerge(
OpenApi.annotations({
identifier: "part.update",
description: "Update a part in a message.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
description: "Experimental HttpApi session routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) =>
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const statusSvc = yield* SessionStatus.Service
@@ -0,0 +1,54 @@
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { lte } from "drizzle-orm"
import { not } from "drizzle-orm"
import { or } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload } from "../groups/sync"
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
return true
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
SyncEvent.replayAll(events)
return { sessionID: events[0].aggregateID }
})
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
})
return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
@@ -0,0 +1,134 @@
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../../tui"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"
const commandAliases = {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
} as const
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
bus.publish(TuiEvent.CommandExecute, { command })
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
const openHelp = Effect.fn("TuiHttpApi.openHelp")(function* () {
yield* publishCommand("help.show")
return true
})
const openSessions = Effect.fn("TuiHttpApi.openSessions")(function* () {
yield* publishCommand("session.list")
return true
})
const openThemes = Effect.fn("TuiHttpApi.openThemes")(function* () {
yield* publishCommand("session.list")
return true
})
const openModels = Effect.fn("TuiHttpApi.openModels")(function* () {
yield* publishCommand("model.list")
return true
})
const submitPrompt = Effect.fn("TuiHttpApi.submitPrompt")(function* () {
yield* publishCommand("prompt.submit")
return true
})
const clearPrompt = Effect.fn("TuiHttpApi.clearPrompt")(function* () {
yield* publishCommand("prompt.clear")
return true
})
const executeCommand = Effect.fn("TuiHttpApi.executeCommand")(function* (ctx: {
payload: typeof CommandPayload.Type
}) {
yield* publishCommand(commandAliases[ctx.payload.command as keyof typeof commandAliases] ?? ctx.payload.command)
return true
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
const row = yield* Effect.sync(() =>
Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),
),
)
if (!row) return yield* new HttpApiError.NotFound({})
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
const controlNext = Effect.fn("TuiHttpApi.controlNext")(function* () {
return yield* Effect.promise(() => nextTuiRequest())
})
const controlResponse = Effect.fn("TuiHttpApi.controlResponse")(function* (ctx: { payload: unknown }) {
submitTuiResponse(ctx.payload)
return true
})
return handlers
.handle("appendPrompt", appendPrompt)
.handle("openHelp", openHelp)
.handle("openSessions", openSessions)
.handle("openThemes", openThemes)
.handle("openModels", openModels)
.handle("submitPrompt", submitPrompt)
.handle("clearPrompt", clearPrompt)
.handle("executeCommand", executeCommand)
.handle("showToast", showToast)
.handle("publish", publish)
.handle("selectSession", selectSession)
.handle("controlNext", controlNext)
.handle("controlResponse", controlResponse)
}),
)
@@ -0,0 +1,66 @@
import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, SessionRestorePayload } from "../groups/workspace"
export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdaptors(instance.project.id))
})
const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
return Workspace.list((yield* InstanceState.context).project)
})
const create = Effect.fn("WorkspaceHttpApi.create")(function* (ctx: { payload: typeof CreatePayload.Type }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.create({
...ctx.payload,
projectID: instance.project.id,
}),
),
)
})
const status = Effect.fn("WorkspaceHttpApi.status")(function* () {
const ids = new Set(Workspace.list((yield* InstanceState.context).project).map((item) => item.id))
return Workspace.status().filter((item) => ids.has(item.workspaceID))
})
const remove = Effect.fn("WorkspaceHttpApi.remove")(function* (ctx: { params: { id: Workspace.Info["id"] } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() => Instance.restore(instance, () => Workspace.remove(ctx.params.id)))
})
const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: {
params: { id: Workspace.Info["id"] }
payload: typeof SessionRestorePayload.Type
}) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
Instance.restore(instance, () =>
Workspace.sessionRestore({
workspaceID: ctx.params.id,
sessionID: ctx.payload.sessionID,
}),
),
)
})
return handlers
.handle("adaptors", adaptors)
.handle("list", list)
.handle("create", create)
.handle("status", status)
.handle("remove", remove)
.handle("sessionRestore", sessionRestore)
}),
)
@@ -0,0 +1,66 @@
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Filesystem } from "@/util/filesystem"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
const Query = Schema.Struct({
directory: Schema.optionalKey(Schema.String),
workspace: Schema.optionalKey(Schema.String),
auth_token: Schema.optionalKey(Schema.String),
})
const Headers = Schema.Struct({
authorization: Schema.optionalKey(Schema.String),
"x-opencode-directory": Schema.optionalKey(Schema.String),
})
export class InstanceContextMiddleware extends HttpApiMiddleware.Service<InstanceContextMiddleware>()(
"@opencode/ExperimentalHttpApiInstanceContext",
) {}
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
function currentDirectory() {
try {
return Instance.directory
} catch {
return process.cwd()
}
}
function provideInstanceContext<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query).pipe(Effect.orDie)
const headers = yield* HttpServerRequest.schemaHeaders(Headers).pipe(Effect.orDie)
const raw = query.directory || headers["x-opencode-directory"] || currentDirectory()
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, query.workspace),
)
})
}
export const instanceContextLayer = Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
)
export const instanceRouterLayer = HttpRouter.middleware()(Effect.succeed((effect) => provideInstanceContext(effect))).layer
@@ -1,157 +0,0 @@
import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/provider"
export const ProviderApi = HttpApi.make("provider")
.add(
HttpApiGroup.make("provider")
.add(
HttpApiEndpoint.get("list", root, {
success: Provider.ListResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.list",
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
}),
),
HttpApiEndpoint.get("auth", `${root}/auth`, {
success: ProviderAuth.Methods,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.auth",
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: Schema.UndefinedOr(ProviderAuth.Authorization),
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
summary: "Start OAuth authorization",
description: "Start the OAuth authorization flow for a provider.",
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
payload: ProviderAuth.CallbackInput,
success: Schema.Boolean,
error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.callback",
summary: "Handle OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
const svc = yield* ProviderAuth.Service
const list = Effect.fn("ProviderHttpApi.list")(function* () {
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
const connected = yield* provider.list()
const providers = Object.assign(
mapValues(filtered, (item) => Provider.fromModelsDevProvider(item)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
})
const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.AuthorizeInput
}) {
const result = yield* svc
.authorize({
providerID: ctx.params.providerID,
method: ctx.payload.method,
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return result
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
}) {
yield* svc
.callback({
providerID: ctx.params.providerID,
method: ctx.payload.method,
code: ctx.payload.code,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
return true
})
return handlers
.handle("list", list)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
}),
)
@@ -1,242 +0,0 @@
import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
import { Effect, Schema } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { Authorization } from "./auth"
const root = "/pty"
const Params = Schema.Struct({
ptyID: PtyID,
})
const CursorQuery = Schema.Struct({
cursor: Schema.optional(Schema.String),
})
const ShellItem = Schema.Struct({
path: Schema.String,
name: Schema.String,
acceptable: Schema.Boolean,
})
export const PtyPaths = {
shells: `${root}/shells`,
list: root,
create: root,
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connect: `${root}/:ptyID/connect`,
} as const
export const PtyApi = HttpApi.make("pty")
.add(
HttpApiGroup.make("pty")
.add(
HttpApiEndpoint.get("shells", PtyPaths.shells, {
success: Schema.Array(ShellItem),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.shells",
summary: "List available shells",
description: "Get a list of available shells on the system.",
}),
),
HttpApiEndpoint.get("list", PtyPaths.list, {
success: Schema.Array(Pty.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.list",
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
}),
),
HttpApiEndpoint.post("create", PtyPaths.create, {
payload: Pty.CreateInput,
success: Pty.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
}),
),
HttpApiEndpoint.get("get", PtyPaths.get, {
params: { ptyID: PtyID },
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.get",
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.put("update", PtyPaths.update, {
params: { ptyID: PtyID },
payload: Pty.UpdateInput,
success: Pty.Info,
error: HttpApiError.NotFound,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.update",
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.delete("remove", PtyPaths.remove, {
params: { ptyID: PtyID },
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "pty",
description: "Experimental HttpApi PTY routes.",
}),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
return yield* pty.list()
})
const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
bridge.promise(
pty.create({
...ctx.payload,
args: ctx.payload.args ? [...ctx.payload.args] : undefined,
env: ctx.payload.env ? { ...ctx.payload.env } : undefined,
}),
),
)
})
const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) {
const info = yield* pty.get(ctx.params.ptyID)
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const update = Effect.fn("PtyHttpApi.update")(function* (ctx: {
params: { ptyID: PtyID }
payload: typeof Pty.UpdateInput.Type
}) {
const info = yield* pty.update(ctx.params.ptyID, {
...ctx.payload,
size: ctx.payload.size ? { ...ctx.payload.size } : undefined,
})
if (!info) return yield* new HttpApiError.NotFound({})
return info
})
const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) {
yield* pty.remove(ctx.params.ptyID)
return true
})
return handlers
.handle("shells", shells)
.handle("list", list)
.handle("create", create)
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
}),
)
export const ptyConnectRoute = HttpRouter.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const pty = yield* Pty.Service
const params = yield* HttpRouter.schemaPathParams(Params)
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const write = yield* socket.writer
let closed = false
const adapter = {
get readyState() {
return closed ? 3 : 1
},
send: (data: string | Uint8Array | ArrayBuffer) => {
if (closed) return
Effect.runFork(
write(data instanceof ArrayBuffer ? new Uint8Array(data) : data).pipe(Effect.catch(() => Effect.void)),
)
},
close: (code?: number, reason?: string) => {
if (closed) return
closed = true
Effect.runFork(write(new Socket.CloseEvent(code, reason)).pipe(Effect.catch(() => Effect.void)))
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
yield* socket
.runRaw((message) => {
handler.onMessage(typeof message === "string" ? message : message.slice().buffer)
})
.pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.ensuring(
Effect.sync(() => {
closed = true
handler.onClose()
}),
),
Effect.orDie,
)
return HttpServerResponse.empty()
}).pipe(Effect.provide(Pty.defaultLayer)),
)
@@ -1,21 +1,5 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { ConfigApi } from "./config"
import { ControlApi } from "./control"
import { EventApi } from "./event"
import { ExperimentalApi } from "./experimental"
import { FileApi } from "./file"
import { GlobalApi } from "./global"
import { InstanceApi } from "./instance"
import { McpApi } from "./mcp"
import { PermissionApi } from "./permission"
import { ProjectApi } from "./project"
import { ProviderApi } from "./provider"
import { PtyApi, PtyConnectApi } from "./pty"
import { QuestionApi } from "./question"
import { SessionApi } from "./session"
import { SyncApi } from "./sync"
import { TuiApi } from "./tui"
import { WorkspaceApi } from "./workspace"
import { OpenApi } from "effect/unstable/httpapi"
import { OpenCodeHttpApi } from "./api"
type OpenApiParameter = {
name: string
@@ -208,25 +192,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) {
param.schema = normalizeRequestSchema(param.schema)
}
export const PublicApi = HttpApi.make("opencode")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
.addHttpApi(EventApi)
.addHttpApi(ConfigApi)
.addHttpApi(ExperimentalApi)
.addHttpApi(FileApi)
.addHttpApi(InstanceApi)
.addHttpApi(McpApi)
.addHttpApi(PermissionApi)
.addHttpApi(ProjectApi)
.addHttpApi(ProviderApi)
.addHttpApi(PtyApi)
.addHttpApi(PtyConnectApi)
.addHttpApi(QuestionApi)
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(WorkspaceApi)
export const PublicApi = OpenCodeHttpApi
.annotateMerge(
OpenApi.annotations({
title: "opencode",
@@ -1,14 +1,12 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Command } from "@/command"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
@@ -16,8 +14,6 @@ import { Format } from "@/format"
import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Installation } from "@/installation"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
@@ -32,82 +28,34 @@ import { Todo } from "@/session/todo"
import { Skill } from "@/skill"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { ControlApi, controlHandlers } from "./control"
import { eventRoute } from "./event"
import { FileApi, fileHandlers } from "./file"
import { ExperimentalApi, experimentalHandlers } from "./experimental"
import { GlobalApi, globalHandlers } from "./global"
import { InstanceApi, instanceHandlers } from "./instance"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { PtyApi, ptyConnectRoute, ptyHandlers } from "./pty"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { SessionApi, sessionHandlers } from "./session"
import { SyncApi, syncHandlers } from "./sync"
import { TuiApi, tuiHandlers } from "./tui"
import { WorkspaceApi, workspaceHandlers } from "./workspace"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
import { experimentalHandlers } from "./handlers/experimental"
import { fileHandlers } from "./handlers/file"
import { globalHandlers } from "./handlers/global"
import { instanceHandlers } from "./handlers/instance"
import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer, instanceRouterLayer } from "./instance-context"
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
auth_token: Schema.optional(Schema.String),
})
const Headers = Schema.Struct({
authorization: Schema.optional(Schema.String),
"x-opencode-directory": Schema.optional(Schema.String),
})
export const context = Context.empty() as Context.Context<unknown>
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
function currentDirectory() {
try {
return Instance.directory
} catch {
return process.cwd()
}
}
const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
const raw = query.directory || headers["x-opencode-directory"] || currentDirectory()
const workspace = query.workspace || undefined
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
})
}),
).layer
const runtime = HttpRouter.middleware()(
Effect.succeed((effect) =>
Effect.gen(function* () {
@@ -118,25 +66,8 @@ const runtime = HttpRouter.middleware()(
),
).layer
const rootApiRoutes = Layer.mergeAll(HttpApiBuilder.layer(ControlApi), HttpApiBuilder.layer(GlobalApi)).pipe(
Layer.provide([controlHandlers, globalHandlers]),
)
const instanceApiRoutes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigApi),
HttpApiBuilder.layer(ExperimentalApi),
HttpApiBuilder.layer(FileApi),
HttpApiBuilder.layer(InstanceApi),
HttpApiBuilder.layer(McpApi),
HttpApiBuilder.layer(ProjectApi),
HttpApiBuilder.layer(PtyApi),
HttpApiBuilder.layer(QuestionApi),
HttpApiBuilder.layer(PermissionApi),
HttpApiBuilder.layer(ProviderApi),
HttpApiBuilder.layer(SessionApi),
HttpApiBuilder.layer(SyncApi),
HttpApiBuilder.layer(TuiApi),
HttpApiBuilder.layer(WorkspaceApi),
).pipe(
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
configHandlers,
experimentalHandlers,
@@ -155,8 +86,9 @@ const instanceApiRoutes = Layer.mergeAll(
]),
)
const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe(
Layer.provide([authorizationLayer, instance]),
const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([authorizationLayer, instanceContextLayer]),
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import * as Log from "@opencode-ai/core/util/log"
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect"
import path from "path"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { Instance } from "../../src/project/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -4,7 +4,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -4,8 +4,8 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { MessageID, PartID } from "../../src/session/schema"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
@@ -3,7 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/mcp"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
@@ -3,7 +3,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/pty"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -5,7 +5,7 @@ import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/sync"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
@@ -3,7 +3,7 @@ import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/tui"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
import { callTui } from "../../src/server/routes/instance/tui"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
@@ -6,7 +6,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"