Merge branch 'dev' into kit/ripgrep-schema-source

This commit is contained in:
Kit Langton
2026-04-13 13:45:51 -04:00
committed by GitHub
63 changed files with 2997 additions and 1076 deletions
+8 -1
View File
@@ -125,7 +125,14 @@ function parseToolParams(input?: string) {
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = MessageID.ascending()
const model = agent.model ?? (await Provider.defaultModel())
const model =
agent.model ??
(await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* provider.defaultModel()
}),
))
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
@@ -1,5 +1,6 @@
import { EOL } from "os"
import { Config } from "../../../config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -9,7 +10,7 @@ export const ConfigCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const config = await Config.get()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
})
},
+22 -4
View File
@@ -1,4 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -15,7 +17,11 @@ const FileSearchCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await File.search({ query: args.query })
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
}),
)
process.stdout.write(results.join(EOL) + EOL)
})
},
@@ -32,7 +38,11 @@ const FileReadCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const content = await File.read(args.path)
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(args.path))
}),
)
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
})
},
@@ -44,7 +54,11 @@ const FileStatusCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
const status = await File.status()
const status = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
})
},
@@ -61,7 +75,11 @@ const FileListCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await File.list(args.path)
const files = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(args.path))
}),
)
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
})
},
+14 -5
View File
@@ -1,4 +1,6 @@
import { LSP } from "../../../lsp"
import { AppRuntime } from "../../../effect/app-runtime"
import { Effect } from "effect"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
@@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
const out = await AppRuntime.runPromise(
LSP.Service.use((lsp) =>
Effect.gen(function* () {
yield* lsp.touchFile(args.file, true)
yield* Effect.sleep(1000)
return yield* lsp.diagnostics()
}),
),
)
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
})
},
})
@@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
@@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri)
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
+82 -46
View File
@@ -15,6 +15,8 @@ import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "../../util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
@@ -50,6 +52,47 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote"
}
function configuredServers(config: Config.Info) {
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
}
function oauthServers(config: Config.Info) {
return configuredServers(config).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
}
async function listState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const statuses = yield* mcp.status()
const stored = yield* Effect.all(
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
{ concurrency: "unbounded" },
)
return { config, statuses, stored }
}),
)
}
async function authState() {
return AppRuntime.runPromise(
Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const auth = yield* Effect.all(
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
{ concurrency: "unbounded" },
)
return { config, auth }
}),
)
}
export const McpCommand = cmd({
command: "mcp",
describe: "manage MCP (Model Context Protocol) servers",
@@ -75,13 +118,8 @@ export const McpListCommand = cmd({
UI.empty()
prompts.intro("MCP Servers")
const config = await Config.get()
const mcpServers = config.mcp ?? {}
const statuses = await MCP.status()
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
isMcpConfigured(entry[1]),
)
const { config, statuses, stored } = await listState()
const servers = configuredServers(config)
if (servers.length === 0) {
prompts.log.warn("No MCP servers configured")
@@ -92,7 +130,7 @@ export const McpListCommand = cmd({
for (const [name, serverConfig] of servers) {
const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = await MCP.hasStoredTokens(name)
const hasStoredTokens = stored[name]
let statusIcon: string
let statusText: string
@@ -152,15 +190,11 @@ export const McpAuthCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Authentication")
const config = await Config.get()
const { config, auth } = await authState()
const mcpServers = config.mcp ?? {}
const servers = oauthServers(config)
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(`
@@ -177,19 +211,17 @@ export const McpAuthCommand = cmd({
let serverName = args.name
if (!serverName) {
// Build options with auth status
const options = await Promise.all(
oauthServers.map(async ([name, cfg]) => {
const authStatus = await MCP.getAuthStatus(name)
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
}),
)
const options = servers.map(([name, cfg]) => {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
})
const selected = await prompts.select({
message: "Select MCP server to authenticate",
@@ -213,7 +245,8 @@ export const McpAuthCommand = cmd({
}
// Check if already authenticated
const authStatus = await MCP.getAuthStatus(serverName)
const authStatus =
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
if (authStatus === "authenticated") {
const confirm = await prompts.confirm({
message: `${serverName} already has valid credentials. Re-authenticate?`,
@@ -240,7 +273,7 @@ export const McpAuthCommand = cmd({
})
try {
const status = await MCP.authenticate(serverName)
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
if (status.status === "connected") {
spinner.stop("Authentication successful!")
@@ -289,22 +322,17 @@ export const McpAuthListCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Status")
const config = await Config.get()
const mcpServers = config.mcp ?? {}
const { config, auth } = await authState()
const servers = oauthServers(config)
// Get OAuth-capable servers
const oauthServers = Object.entries(mcpServers).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
if (oauthServers.length === 0) {
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done")
return
}
for (const [name, serverConfig] of oauthServers) {
const authStatus = await MCP.getAuthStatus(name)
for (const [name, serverConfig] of servers) {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url
@@ -312,7 +340,7 @@ export const McpAuthListCommand = cmd({
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
}
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
prompts.outro(`${servers.length} OAuth-capable server(s)`)
},
})
},
@@ -334,7 +362,7 @@ export const McpLogoutCommand = cmd({
prompts.intro("MCP OAuth Logout")
const authPath = path.join(Global.Path.data, "mcp-auth.json")
const credentials = await McpAuth.all()
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
const serverNames = Object.keys(credentials)
if (serverNames.length === 0) {
@@ -372,7 +400,7 @@ export const McpLogoutCommand = cmd({
return
}
await MCP.removeAuth(serverName)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done")
},
@@ -595,7 +623,7 @@ export const McpDebugCommand = cmd({
UI.empty()
prompts.intro("MCP OAuth Debug")
const config = await Config.get()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const serverName = args.name
@@ -622,10 +650,18 @@ export const McpDebugCommand = cmd({
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status
const authStatus = await MCP.getAuthStatus(serverName)
const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
const entry = await McpAuth.get(serverName)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
+41 -31
View File
@@ -6,6 +6,8 @@ import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { EOL } from "os"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const ModelsCommand = cmd({
command: "models [provider]",
@@ -35,43 +37,51 @@ export const ModelsCommand = cmd({
await Instance.provide({
directory: process.cwd(),
async fn() {
const providers = await Provider.list()
await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const providers = yield* svc.list()
function printModels(providerID: ProviderID, verbose?: boolean) {
const provider = providers[providerID]
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sortedModels) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
const print = (providerID: ProviderID, verbose?: boolean) => {
const provider = providers[providerID]
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) {
process.stdout.write(`${providerID}/${modelID}`)
process.stdout.write(EOL)
if (verbose) {
process.stdout.write(JSON.stringify(model, null, 2))
process.stdout.write(EOL)
}
}
}
}
}
if (args.provider) {
const provider = providers[ProviderID.make(args.provider)]
if (!provider) {
UI.error(`Provider not found: ${args.provider}`)
return
}
if (args.provider) {
const providerID = ProviderID.make(args.provider)
const provider = providers[providerID]
if (!provider) {
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
return
}
printModels(ProviderID.make(args.provider), args.verbose)
return
}
yield* Effect.sync(() => print(providerID, args.verbose))
return
}
const providerIDs = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
const ids = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("opencode")
const bIsOpencode = b.startsWith("opencode")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
for (const providerID of providerIDs) {
printModels(ProviderID.make(providerID), args.verbose)
}
yield* Effect.sync(() => {
for (const providerID of ids) {
print(ProviderID.make(providerID), args.verbose)
}
})
}),
)
},
})
},
+1 -1
View File
@@ -326,7 +326,7 @@ export const ProvidersLoginCommand = cmd({
}
await ModelsDev.refresh(true).catch(() => {})
const config = await Config.get()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
@@ -9,6 +9,12 @@ import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type Adaptor = {
type: string
name: string
description: string
}
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
@@ -63,9 +69,27 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => {
dialog.setSize("medium")
void (async () => {
const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir)
const res = await sdk
.fetch(url)
.then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined)
if (!res) {
toast.show({
message: "Failed to load workspace adaptors",
variant: "error",
})
return
}
setAdaptors(res)
})()
})
const options = createMemo(() => {
@@ -79,13 +103,21 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
const list = adaptors()
if (!list) {
return [
{
title: "Loading workspaces...",
value: "loading" as const,
description: "Fetching available workspace adaptors",
},
]
}
return list.map((item) => ({
title: item.name,
value: item.type,
description: item.description,
}))
})
const create = async (type: string) => {
@@ -113,7 +145,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
if (option.value === "creating" || option.value === "loading") return
void create(option.value)
}}
/>
+1 -1
View File
@@ -81,7 +81,7 @@ export const rpc = {
})
},
async reload() {
await Config.invalidate(true)
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
},
async shutdown() {
Log.Default.info("worker shutting down")
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config"
import { AppRuntime } from "@/effect/app-runtime"
const options = {
port: {
@@ -37,7 +38,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
}
export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.getGlobal()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
+1 -1
View File
@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.getGlobal()
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
if (!latest) return
-39
View File
@@ -33,7 +33,6 @@ import { ConfigPaths } from "./paths"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
@@ -1661,42 +1660,4 @@ export namespace Config {
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function getGlobal() {
return runPromise((svc) => svc.getGlobal())
}
export async function getConsoleState() {
return runPromise((svc) => svc.getConsoleState())
}
export async function installDependencies(dir: string, input?: InstallInput) {
return runPromise((svc) => svc.installDependencies(dir, input))
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}
export async function updateGlobal(config: Info) {
return runPromise((svc) => svc.updateGlobal(config))
}
export async function invalidate(wait = false) {
return runPromise((svc) => svc.invalidate(wait))
}
export async function directories() {
return runPromise((svc) => svc.directories())
}
export async function waitForDependencies() {
return runPromise((svc) => svc.waitForDependencies())
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppRuntime } from "@/effect/app-runtime"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
@@ -51,7 +52,7 @@ export namespace TuiConfig {
}
function installDeps(dir: string): Promise<void> {
return Config.installDependencies(dir)
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
}
async function mergeFile(acc: Acc, file: string) {
@@ -1,20 +1,52 @@
import { lazy } from "@/util/lazy"
import type { Adaptor } from "../types"
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor } from "../types"
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
export type WorkspaceAdaptorEntry = {
type: string
name: string
description: string
}
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
}
export function getAdaptor(type: string): Promise<Adaptor> {
return ADAPTORS[type]()
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin()
throw new Error(`Unknown workspace adaptor: ${type}`)
}
export function installAdaptor(type: string, adaptor: Adaptor) {
// This is experimental: mostly used for testing right now, but we
// will likely allow this in the future. Need to figure out the
// TypeScript story
// @ts-expect-error we force the builtin types right now, but we
// will implement a way to extend the types for custom adaptors
ADAPTORS[type] = () => adaptor
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, init]) => {
const adaptor = await init()
return {
type,
name: adaptor.name,
description: adaptor.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
type,
name: adaptor.name,
description: adaptor.description,
}))
return [...builtin, ...custom]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
adaptors.set(type, adaptor)
state.set(projectID, adaptors)
}
@@ -1,18 +1,18 @@
import z from "zod"
import { Worktree } from "@/worktree"
import { type Adaptor, WorkspaceInfo } from "../types"
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const Config = WorkspaceInfo.extend({
name: WorkspaceInfo.shape.name.unwrap(),
const WorktreeConfig = z.object({
name: WorkspaceInfo.shape.name,
branch: WorkspaceInfo.shape.branch.unwrap(),
directory: WorkspaceInfo.shape.directory.unwrap(),
})
type Config = z.infer<typeof Config>
export const WorktreeAdaptor: Adaptor = {
export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree",
description: "Create a git worktree",
async configure(info) {
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
const worktree = await Worktree.makeWorktreeInfo(undefined)
return {
...info,
name: worktree.name,
@@ -21,7 +21,7 @@ export const WorktreeAdaptor: Adaptor = {
}
},
async create(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
await Worktree.createFromInfo({
name: config.name,
directory: config.directory,
@@ -29,11 +29,11 @@ export const WorktreeAdaptor: Adaptor = {
})
},
async remove(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
await Worktree.remove({ directory: config.directory })
},
target(info) {
const config = Config.parse(info)
const config = WorktreeConfig.parse(info)
return {
type: "local",
directory: config.directory,
+8 -6
View File
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
export const WorkspaceInfo = z.object({
id: WorkspaceID.zod,
type: z.string(),
name: z.string(),
branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: ProjectID.zod,
@@ -24,9 +24,11 @@ export type Target =
headers?: HeadersInit
}
export type Adaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
target(config: WorkspaceInfo): Target | Promise<Target>
export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
name: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
@@ -9,6 +9,7 @@ import { SyncEvent } from "@/sync"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors"
import { WorkspaceInfo } from "./types"
@@ -66,9 +67,9 @@ export namespace Workspace {
export const create = fn(CreateInput, async (input) => {
const id = WorkspaceID.ascending(input.id)
const adaptor = await getAdaptor(input.type)
const adaptor = await getAdaptor(input.projectID, input.type)
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
const info: Info = {
id,
@@ -124,7 +125,7 @@ export namespace Workspace {
stopSync(id)
const info = fromRow(row)
const adaptor = await getAdaptor(row.type)
const adaptor = await getAdaptor(info.projectID, row.type)
adaptor.remove(info)
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
return info
@@ -162,7 +163,7 @@ export namespace Workspace {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type)
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return
-23
View File
@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git"
import { Effect, Layer, Context } from "effect"
@@ -644,26 +643,4 @@ export namespace File {
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
export async function status() {
return runPromise((svc) => svc.status())
}
export async function read(file: string): Promise<Content> {
return runPromise((svc) => svc.read(file))
}
export async function list(dir?: string) {
return runPromise((svc) => svc.list(dir))
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromise((svc) => svc.search(input))
}
}
-32
View File
@@ -13,7 +13,6 @@ import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -508,37 +507,6 @@ export namespace LSP {
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const init = async () => runPromise((svc) => svc.init())
export const status = async () => runPromise((svc) => svc.status())
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic {
const MAX_PER_FILE = 20
-34
View File
@@ -27,7 +27,6 @@ import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -890,37 +889,4 @@ export namespace MCP {
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
// --- Async facade functions ---
export const status = async () => runPromise((svc) => svc.status())
export const tools = async () => runPromise((svc) => svc.tools())
export const prompts = async () => runPromise((svc) => svc.prompts())
export const resources = async () => runPromise((svc) => svc.resources())
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
}
+14 -1
View File
@@ -1,4 +1,10 @@
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
import type {
Hooks,
PluginInput,
Plugin as PluginInstance,
PluginModule,
WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin"
import { Config } from "../config/config"
import { Bus } from "../bus"
import { Log } from "../util/log"
@@ -18,6 +24,8 @@ import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdaptor } from "@/control-plane/types"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -132,6 +140,11 @@ export namespace Plugin {
project: ctx.project,
worktree: ctx.worktree,
directory: ctx.directory,
experimental_workspace: {
register(type: string, adaptor: PluginWorkspaceAdaptor) {
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
},
},
get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096")
},
@@ -21,7 +21,6 @@ import path from "path"
import { Effect, Layer, Context } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { isRecord } from "@/util/record"
@@ -1693,36 +1692,6 @@ export namespace Provider {
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
export async function getProvider(providerID: ProviderID) {
return runPromise((svc) => svc.getProvider(providerID))
}
export async function getModel(providerID: ProviderID, modelID: ModelID) {
return runPromise((svc) => svc.getModel(providerID, modelID))
}
export async function getLanguage(model: Model) {
return runPromise((svc) => svc.getLanguage(model))
}
export async function closest(providerID: ProviderID, query: string[]) {
return runPromise((svc) => svc.closest(providerID, query))
}
export async function getSmallModel(providerID: ProviderID) {
return runPromise((svc) => svc.getSmallModel(providerID))
}
export async function defaultModel() {
return runPromise((svc) => svc.defaultModel())
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
export function sort<T extends { id: string }>(models: T[]) {
return sortBy(
@@ -7,6 +7,8 @@ import { mapValues } from "remeda"
import { errors } from "../error"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
const log = Log.create({ service: "server" })
@@ -30,7 +32,7 @@ export const ConfigRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await Config.get())
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
},
)
.patch(
@@ -54,7 +56,7 @@ export const ConfigRoutes = lazy(() =>
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
return c.json(config)
},
)
@@ -82,7 +84,12 @@ export const ConfigRoutes = lazy(() =>
}),
async (c) => {
using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
const providers = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
return mapValues(yield* svc.list(), (item) => item)
}),
)
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
@@ -408,7 +408,14 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await MCP.resources())
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
},
),
)
+28 -14
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod"
import { AppRuntime } from "../../effect/app-runtime"
import { File } from "../../file"
@@ -72,12 +73,18 @@ export const FileRoutes = lazy(() =>
const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type
const limit = c.req.valid("query").limit
const results = await File.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
})
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) =>
svc.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
}),
)
}),
)
return c.json(results)
},
)
@@ -105,11 +112,6 @@ export const FileRoutes = lazy(() =>
}),
),
async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([])
},
)
@@ -138,7 +140,11 @@ export const FileRoutes = lazy(() =>
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.list(path)
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(path))
}),
)
return c.json(content)
},
)
@@ -167,7 +173,11 @@ export const FileRoutes = lazy(() =>
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(path))
}),
)
return c.json(content)
},
)
@@ -189,7 +199,11 @@ export const FileRoutes = lazy(() =>
},
}),
async (c) => {
const content = await File.status()
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
return c.json(content)
},
),
@@ -199,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await Config.getGlobal())
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
},
)
.patch(
@@ -223,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
const next = await Config.updateGlobal(config)
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
return c.json(next)
},
)
@@ -256,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
return c.json(await LSP.status())
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
return c.json(items)
},
)
.get(
+34 -14
View File
@@ -3,8 +3,10 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config/config"
import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Effect } from "effect"
export const McpRoutes = lazy(() =>
new Hono()
@@ -26,7 +28,7 @@ export const McpRoutes = lazy(() =>
},
}),
async (c) => {
return c.json(await MCP.status())
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
},
)
.post(
@@ -56,7 +58,7 @@ export const McpRoutes = lazy(() =>
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config)
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
return c.json(result.status)
},
)
@@ -84,12 +86,21 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
auth: yield* mcp.startAuth(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const result = await MCP.startAuth(name)
return c.json(result)
return c.json(result.auth)
},
)
.post(
@@ -120,7 +131,7 @@ export const McpRoutes = lazy(() =>
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await MCP.finishAuth(name, code)
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
return c.json(status)
},
)
@@ -144,12 +155,21 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
if (!supports) return { supports }
return {
supports,
status: yield* mcp.authenticate(name),
}
}),
)
if (!result.supports) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const status = await MCP.authenticate(name)
return c.json(status)
return c.json(result.status)
},
)
.delete(
@@ -172,7 +192,7 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
await MCP.removeAuth(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
return c.json({ success: true as const })
},
)
@@ -195,7 +215,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.connect(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
return c.json(true)
},
)
@@ -218,7 +238,7 @@ export const McpRoutes = lazy(() =>
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.disconnect(name)
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
return c.json(true)
},
),
@@ -95,7 +95,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
})
}
const adaptor = await getAdaptor(workspace.type)
const adaptor = await getAdaptor(workspace.projectID, workspace.type)
const target = await adaptor.target(workspace)
if (target.type === "local") {
@@ -11,6 +11,7 @@ import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Log } from "../../util/log"
import { Effect } from "effect"
const log = Log.create({ service: "server" })
@@ -40,27 +41,36 @@ export const ProviderRoutes = lazy(() =>
},
}),
async (c) => {
const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = await ModelsDev.get()
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filteredProviders[key] = value
}
}
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const cfg = yield* Config.Service
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* svc.list()
const providers = Object.assign(
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
}
}),
)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
all: result.all,
default: result.default,
connected: result.connected,
})
},
)
@@ -1,13 +1,41 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { listAdaptors } from "../../control-plane/adaptors"
import { Workspace } from "../../control-plane/workspace"
import { Instance } from "../../project/instance"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const WorkspaceAdaptor = z.object({
type: z.string(),
name: z.string(),
description: z.string(),
})
export const WorkspaceRoutes = lazy(() =>
new Hono()
.get(
"/adaptor",
describeRoute({
summary: "List workspace adaptors",
description: "List all available workspace adaptors for the current project.",
operationId: "experimental.workspace.adaptor.list",
responses: {
200: {
description: "Workspace adaptors",
content: {
"application/json": {
schema: resolver(z.array(WorkspaceAdaptor)),
},
},
},
},
}),
async (c) => {
return c.json(await listAdaptors(Instance.project.id))
},
)
.post(
"/",
describeRoute({