Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions packages/opencode/specs/effect/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `file` | `bridged` partial | list/content/status only |
| `mcp` | `bridged` partial | status only |
| `workspace` | `bridged` | list, get, enter |
| top-level instance reads | `bridged` partial | path and vcs reads; command, agent, skill, lsp, formatter next |
| top-level instance reads | `bridged` | path, vcs, command, agent, skill, lsp, formatter |
| experimental JSON routes | `next/later` | console, tool, worktree, resource, global session list |
| `session` | `later/special` | large stateful surface plus streaming |
| `sync` | `later` | process/control side effects |
Expand All @@ -151,8 +151,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
## Next PRs

1. Produce a generated route inventory from Hono registrations and update `Current Route Status` with exact paths.
2. Continue porting top-level JSON reads.
3. Start the Effect OpenAPI/SDK generation path for already-bridged routes.
2. Start the Effect OpenAPI/SDK generation path for already-bridged routes.

## Checklist

Expand All @@ -165,7 +164,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
- [x] Add bridge-level auth and instance tests.
- [ ] Complete exact Hono route inventory.
- [x] Resolve implemented-but-unmounted route groups.
- [ ] Port remaining JSON routes.
- [x] Port remaining top-level JSON reads.
- [ ] Generate SDK/OpenAPI from Effect routes.
- [ ] Flip ported JSON routes to default-on with fallback.
- [ ] Delete replaced Hono route implementations.
Expand Down
59 changes: 29 additions & 30 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import z from "zod"
import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Instance } from "../project/instance"
import { Truncate } from "../tool"
import { Auth } from "../auth"
import { ProviderTransform } from "../provider"
Expand All @@ -19,37 +18,37 @@ import { Global } from "@opencode-ai/core/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect"
import { Effect, Context, Layer, Schema } from "effect"
import { InstanceState } from "@/effect"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@/util/effect-zod"
import { withStatics, type DeepMutable } from "@/util/schema"

export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
providerID: ProviderID.zod,
})
.optional(),
variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
})
.meta({
ref: "Agent",
})
export type Info = z.infer<typeof Info>
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
mode: Schema.Literals(["subagent", "primary", "all"]),
native: Schema.optional(Schema.Boolean),
hidden: Schema.optional(Schema.Boolean),
topP: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
}),
),
variant: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.String),
options: Schema.Record(Schema.String, Schema.Unknown),
steps: Schema.optional(Schema.Number),
})
.annotate({ identifier: "Agent" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>

export interface Interface {
readonly get: (agent: string) => Effect.Effect<Info>
Expand Down Expand Up @@ -79,7 +78,7 @@ export const layer = Layer.effect(
const provider = yield* Provider.Service

const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
Expand Down Expand Up @@ -136,7 +135,7 @@ export const layer = Layer.effect(
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
[path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
Expand Down
33 changes: 16 additions & 17 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { InstanceContext } from "@/project/instance"
import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context, Schema } from "effect"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { Config } from "../config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
Expand All @@ -27,25 +29,22 @@ export const Event = {
),
}

export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
source: z.enum(["command", "mcp", "skill"]).optional(),
// workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
export const Info = Schema.Struct({
name: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
// Some command templates are lazy promises from MCP prompt resolution.
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
subtask: Schema.optional(Schema.Boolean),
hints: Schema.Array(Schema.String),
})
.annotate({ identifier: "Command" })
.pipe(withStatics((s) => ({ zod: zod(s) })))

// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }

export function hints(template: string) {
const result: string[] = []
Expand Down
23 changes: 11 additions & 12 deletions packages/opencode/src/format/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { Effect, Layer, Context } from "effect"
import { Effect, Layer, Context, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect"
import path from "path"
import { mergeDeep } from "remeda"
import z from "zod"
import { Config } from "../config"
import { Log } from "../util"
import * as Formatter from "./formatter"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

const log = Log.create({ service: "format" })

export const Status = z
.object({
name: z.string(),
extensions: z.string().array(),
enabled: z.boolean(),
})
.meta({
ref: "FormatterStatus",
})
export type Status = z.infer<typeof Status>
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
enabled: Schema.Boolean,
})
.annotate({ identifier: "FormatterStatus" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Status = Schema.Schema.Type<typeof Status>

export interface Interface {
readonly init: () => Effect.Effect<void>
Expand Down
9 changes: 5 additions & 4 deletions packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ export const Action = Schema.Literals(["allow", "deny", "ask"])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = Schema.Schema.Type<typeof Action>

export class Rule extends Schema.Class<Rule>("PermissionRule")({
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}) {
static readonly zod = zod(this)
}
})
.annotate({ identifier: "PermissionRule" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>

export const Ruleset = Schema.mutable(Schema.Array(Rule))
.annotate({ identifier: "PermissionRuleset" })
Expand Down
99 changes: 97 additions & 2 deletions packages/opencode/src/server/routes/instance/httpapi/instance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { Format } from "@/format"
import { Global } from "@opencode-ai/core/global"
import { LSP } from "@/lsp"
import { Vcs } from "@/project"
import { Skill } from "@/skill"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
Expand All @@ -21,6 +26,11 @@ export const InstancePaths = {
path: "/path",
vcs: "/vcs",
vcsDiff: "/vcs/diff",
command: "/command",
agent: "/agent",
skill: "/skill",
lsp: "/lsp",
formatter: "/formatter",
} as const

export const InstanceApi = HttpApi.make("instance")
Expand Down Expand Up @@ -57,6 +67,51 @@ export const InstanceApi = HttpApi.make("instance")
description: "Retrieve the current git diff for the working tree or against the default branch.",
}),
),
HttpApiEndpoint.get("command", InstancePaths.command, {
success: Schema.Array(Command.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "command.list",
summary: "List commands",
description: "Get a list of all available commands in the OpenCode system.",
}),
),
HttpApiEndpoint.get("agent", InstancePaths.agent, {
success: Schema.Array(Agent.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "app.agents",
summary: "List agents",
description: "Get a list of all available AI agents in the OpenCode system.",
}),
),
HttpApiEndpoint.get("skill", InstancePaths.skill, {
success: Schema.Array(Skill.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "app.skills",
summary: "List skills",
description: "Get a list of all available skills in the OpenCode system.",
}),
),
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
success: Schema.Array(LSP.Status),
}).annotateMerge(
OpenApi.annotations({
identifier: "lsp.status",
summary: "Get LSP status",
description: "Get LSP server status",
}),
),
HttpApiEndpoint.get("formatter", InstancePaths.formatter, {
success: Schema.Array(Format.Status),
}).annotateMerge(
OpenApi.annotations({
identifier: "formatter.status",
summary: "Get formatter status",
description: "Get formatter status",
}),
),
)
.annotateMerge(
OpenApi.annotations({
Expand All @@ -76,6 +131,11 @@ export const InstanceApi = HttpApi.make("instance")

export const instanceHandlers = Layer.unwrap(
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 getPath = Effect.fn("InstanceHttpApi.path")(function* () {
Expand All @@ -98,8 +158,43 @@ export const instanceHandlers = Layer.unwrap(
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 HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
handlers.handle("path", getPath).handle("vcs", getVcs).handle("vcsDiff", getVcsDiff),
handlers
.handle("path", getPath)
.handle("vcs", getVcs)
.handle("vcsDiff", getVcsDiff)
.handle("command", getCommand)
.handle("agent", getAgent)
.handle("skill", getSkill)
.handle("lsp", getLsp)
.handle("formatter", getFormatter),
)
}),
).pipe(Layer.provide(Vcs.defaultLayer))
).pipe(
Layer.provide(Agent.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(Vcs.defaultLayer),
)
Loading
Loading