Compare commits

..
Author SHA1 Message Date
Aiden Cline 020b2240c2 docs: update SDK docs to reference v2 SDK exclusively
- Update all imports to @opencode-ai/sdk/v2
- Fix types URL to point to v2 generated types
- Fix file.read response type: text|binary (not raw|patch)
- Fix permission method name: permission.reply (not postSessionByIdPermissionsByPermissionId)
2026-04-30 23:22:11 -05:00
75 changed files with 478 additions and 986 deletions
-1
View File
@@ -32,7 +32,6 @@ rekram1-node
-ricardo-m-l -ricardo-m-l
-robinmordasiewicz -robinmordasiewicz
rubdos rubdos
-saisharan0103 spamming ai prs
shantur shantur
simonklee simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
+16 -16
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.14.31", "version": "1.14.30",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -228,7 +228,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -263,7 +263,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -309,7 +309,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -338,7 +338,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -354,7 +354,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.14.31", "version": "1.14.30",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -496,7 +496,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -531,7 +531,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -546,7 +546,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -581,7 +581,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -630,7 +630,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.31", "version": "1.14.30",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
-3
View File
@@ -204,9 +204,6 @@ function createGlobalSync() {
}, },
translate: language.t, translate: language.t,
getSdk: sdkFor, getSdk: sdkFor,
global: {
provider: globalStore.provider,
},
}) })
async function loadSessions(directory: string) { async function loadSessions(directory: string) {
@@ -260,6 +260,9 @@ export async function bootstrapDirectory(input: {
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
if (seededProject) input.setStore("project", seededProject) if (seededProject) input.setStore("project", seededProject)
if (seededPath) input.setStore("path", seededPath) if (seededPath) input.setStore("path", seededPath)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false })) input.setStore("config", reconcile(input.global.config, { merge: false }))
} }
@@ -23,7 +23,6 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
getSdk: () => null!, getSdk: () => null!,
global: { provider: null! },
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client"
import { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@@ -27,9 +27,6 @@ export function createChildStoreManager(input: {
onDispose: (directory: string) => void onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
getSdk: (directory: string) => OpencodeClient getSdk: (directory: string) => OpencodeClient
global: {
provider: ProviderListResponse
}
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>() const vcsCache = new Map<string, VcsCache>()
@@ -192,13 +189,7 @@ export function createChildStoreManager(input: {
get provider_ready() { get provider_ready() {
return !providerQuery.isLoading return !providerQuery.isLoading
}, },
get provider() { provider: { all: [], connected: [], default: {} },
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {}, config: {},
get path() { get path() {
if (pathQuery.isLoading || !pathQuery.data) if (pathQuery.isLoading || !pathQuery.data)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -141,10 +141,7 @@ export async function handler(
) )
validateModelSettings(billingSource, authInfo) validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo) updateProviderKey(authInfo, providerInfo)
logger.metric({ logger.metric({ provider: providerInfo.id })
provider: providerInfo.id,
"provider.model": providerInfo.model,
})
const startTimestamp = Date.now() const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream) const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
@@ -152,23 +149,12 @@ export async function handler(
providerInfo.modifyBody({ providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body), ...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model, model: providerInfo.model,
...(() => { ...providerInfo.payloadModifier,
const replacer = (obj: Record<string, any>): Record<string, any> => ...Object.fromEntries(
Object.fromEntries( Object.entries(providerInfo.payloadMappings ?? {})
Object.entries(obj).flatMap(([k, v]) => { .map(([k, v]) => [k, input.request.headers.get(v)])
if (Array.isArray(v)) return [[k, v]] .filter(([_k, v]) => !!v),
if (typeof v === "object") return [[k, replacer(v)]] ),
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
}
return [[k, v]]
}),
)
return replacer(providerInfo.payloadModifier ?? {})
})(),
}), }),
) )
logger.debug("REQUEST URL: " + reqUrl) logger.debug("REQUEST URL: " + reqUrl)
@@ -528,6 +514,7 @@ export async function handler(
reqModel, reqModel,
providerModel: modelProvider.model, providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage, adjustCacheUsage: providerProps.adjustCacheUsage,
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
workspaceID: authInfo?.workspaceID, workspaceID: authInfo?.workspaceID,
} }
if (format === "anthropic") return anthropicHelper(opts) if (format === "anthropic") return anthropicHelper(opts)
@@ -23,7 +23,7 @@ type Usage = {
} }
} }
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
format: "oa-compat", format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
@@ -34,6 +34,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
return { return {
...body, ...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}), ...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
} }
}, },
createBinaryStreamDecoder: () => undefined, createBinaryStreamDecoder: () => undefined,
@@ -18,7 +18,10 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`) headers.set("authorization", `Bearer ${apiKey}`)
}, },
modifyBody: (body: Record<string, any>) => body, modifyBody: (body: Record<string, any>) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),
createBinaryStreamDecoder: () => undefined, createBinaryStreamDecoder: () => undefined,
streamSeparator: "\n\n", streamSeparator: "\n\n",
createUsageParser: () => { createUsageParser: () => {
@@ -37,6 +37,7 @@ export type ProviderHelper = (input: {
reqModel: string reqModel: string
providerModel: string providerModel: string
adjustCacheUsage?: boolean adjustCacheUsage?: boolean
safetyIdentifier?: string
workspaceID?: string workspaceID?: string
}) => { }) => {
format: ZenData.Format format: ZenData.Format
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.14.31", "version": "1.14.30",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1
View File
@@ -40,6 +40,7 @@ export namespace ZenData {
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
storeModel: z.string().optional(), storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}), }),
), ),
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.31", "version": "1.14.30",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.31", "version": "1.14.30",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.31", "version": "1.14.30",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2 -6
View File
@@ -120,17 +120,13 @@ export const layer = Layer.effect(
} }
})() })()
if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { if (yield* afs.existsSafe(dir)) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name)) return resolveEntryPoint(name, path.join(dir, "node_modules", name))
} }
const tree = yield* reify({ dir, add: [pkg] }) const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to const first = tree.edgesOut.values().next().value?.to
if (!first) { if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
const result = resolveEntryPoint(name, path.join(dir, "node_modules", name))
if (Option.isSome(result.entrypoint)) return result
return yield* new InstallFailedError({ add: [pkg], dir })
}
return resolveEntryPoint(first.name, first.path) return resolveEntryPoint(first.name, first.path)
}, Effect.scoped) }, Effect.scoped)
-16
View File
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Global } from "@opencode-ai/core/global"
describe("global paths", () => {
test("tmp path is under the system temp directory", () => {
expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode"))
expect(Global.make().tmp).toBe(Global.Path.tmp)
})
test("tmp path is created on module load", async () => {
expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true)
})
})
-35
View File
@@ -1,12 +1,7 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32" const win = process.platform === "win32"
@@ -20,14 +15,6 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
}), }),
) )
const npmLayer = (cache: string) =>
Npm.layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })),
Layer.provide(NodeFileSystem.layer),
)
describe("Npm.sanitize", () => { describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => { test("keeps normal scoped package specs unchanged", () => {
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
@@ -42,28 +29,6 @@ describe("Npm.sanitize", () => {
}) })
}) })
describe("Npm.add", () => {
test("reifies when package cache directory exists without the package installed", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "fixture-provider"))
await writePackage(path.join(tmp.path, "fixture-provider"), {
name: "fixture-provider",
main: "index.js",
})
await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n")
const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}`
await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true })
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(Option.isSome(entry.entrypoint)).toBe(true)
})
})
describe("Npm.install", () => { describe("Npm.install", () => {
test("respects omit from project .npmrc", async () => { test("respects omit from project .npmrc", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.31", "version": "1.14.30",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.14.31" version = "1.14.30"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.31", "version": "1.14.30",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.31", "version": "1.14.30",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2 -2
View File
@@ -198,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `project` | `bridged` | list, current, git init, update | | `project` | `bridged` | list, current, git init, update |
| `file` | `bridged` partial | find text/file/symbol, list/content/status | | `file` | `bridged` partial | find text/file/symbol, list/content/status |
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect | | `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | | `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | | top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | | `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
@@ -290,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
### Workspace Routes ### Workspace Routes
- [x] `GET /experimental/workspace/adapter` - list workspace adapters. - [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
- [x] `POST /experimental/workspace` - create workspace. - [x] `POST /experimental/workspace` - create workspace.
- [x] `GET /experimental/workspace` - list workspaces. - [x] `GET /experimental/workspace` - list workspaces.
- [x] `GET /experimental/workspace/status` - workspace status. - [x] `GET /experimental/workspace/status` - workspace status.
+1 -1
View File
@@ -353,7 +353,7 @@ piecewise.
- [ ] `src/cli/cmd/tui/event.ts` - [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts` - [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts` - [ ] `src/command/index.ts`
- [x] `src/control-plane/adapters/worktree.ts` - [x] `src/control-plane/adaptors/worktree.ts`
- [x] `src/control-plane/types.ts` - [x] `src/control-plane/types.ts`
- [x] `src/control-plane/workspace.ts` - [x] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts` - [ ] `src/file/index.ts`
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
type Adapter = { type Adaptor = {
type: string type: string
name: string name: string
description: string description: string
@@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const [creating, setCreating] = createSignal<string>() const [creating, setCreating] = createSignal<string>()
const [adapters, setAdapters] = createSignal<Adapter[]>() const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
onMount(() => { onMount(() => {
dialog.setSize("medium") dialog.setSize("medium")
void (async () => { void (async () => {
const dir = sync.path.directory || sdk.directory const dir = sync.path.directory || sdk.directory
const url = new URL("/experimental/workspace/adapter", sdk.url) const url = new URL("/experimental/workspace/adaptor", sdk.url)
if (dir) url.searchParams.set("directory", dir) if (dir) url.searchParams.set("directory", dir)
const res = await sdk const res = await sdk
.fetch(url) .fetch(url)
.then((x) => x.json() as Promise<Adapter[]>) .then((x) => x.json() as Promise<Adaptor[]>)
.catch(() => undefined) .catch(() => undefined)
if (!res) { if (!res) {
toast.show({ toast.show({
message: "Failed to load workspace adapters", message: "Failed to load workspace adaptors",
variant: "error", variant: "error",
}) })
return return
} }
setAdapters(res) setAdaptors(res)
})() })()
}) })
@@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
}, },
] ]
} }
const list = adapters() const list = adaptors()
if (!list) { if (!list) {
return [ return [
{ {
title: "Loading workspaces...", title: "Loading workspaces...",
value: "loading" as const, value: "loading" as const,
description: "Fetching available workspace adapters", description: "Fetching available workspace adaptors",
}, },
] ]
} }
@@ -1,5 +1,4 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme" import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound" import * as Sound from "@tui/util/sound"
@@ -555,7 +554,6 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState {
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) { export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme() const { theme } = useTheme()
const renderer = useRenderer()
const [rings, setRings] = createSignal<Ring[]>([]) const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>() const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>() const [release, setRelease] = createSignal<Release>()
@@ -686,7 +684,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
}) })
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined)) const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const useSubpixelBlocks = () => renderer.capabilities?.rgb === true
const renderLine = ( const renderLine = (
line: string, line: string,
@@ -792,7 +789,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
} }
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
if (char === "█" && useSubpixelBlocks()) { if (char === "█") {
return ( return (
<text <text
fg={shade(inkTop, theme, n + p + e + b)} fg={shade(inkTop, theme, n + p + e + b)}
@@ -1,45 +0,0 @@
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
import { WorktreeAdapter } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdapter> = {
worktree: WorktreeAdapter,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
const builtin = BUILTIN[type]
if (builtin) return builtin
throw new Error(`Unknown workspace adapter: ${type}`)
}
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adapter]) => {
return {
type,
name: adapter.name,
description: adapter.description,
}
}),
)
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
type,
name: adapter.name,
description: adapter.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 registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter)
state.set(projectID, adapters)
}
@@ -0,0 +1,45 @@
import type { ProjectID } from "@/project/schema"
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
import { WorktreeAdaptor } from "./worktree"
const BUILTIN: Record<string, WorkspaceAdaptor> = {
worktree: WorktreeAdaptor,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
export function getAdaptor(projectID: ProjectID, type: string): 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 async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
const builtin = await Promise.all(
Object.entries(BUILTIN).map(async ([type, adaptor]) => {
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,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { type WorkspaceAdapter, WorkspaceInfo } from "../types" import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
const WorktreeConfig = Schema.Struct({ const WorktreeConfig = Schema.Struct({
name: WorkspaceInfo.fields.name, name: WorkspaceInfo.fields.name,
@@ -13,7 +13,7 @@ async function loadWorktree() {
return { AppRuntime, Worktree } return { AppRuntime, Worktree }
} }
export const WorktreeAdapter: WorkspaceAdapter = { export const WorktreeAdaptor: WorkspaceAdaptor = {
name: "Worktree", name: "Worktree",
description: "Create a git worktree", description: "Create a git worktree",
async configure(info) { async configure(info) {
+3 -3
View File
@@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) }))) .pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>> export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
export const WorkspaceAdapterEntry = Schema.Struct({ export const WorkspaceAdaptorEntry = Schema.Struct({
type: Schema.String, type: Schema.String,
name: Schema.String, name: Schema.String,
description: Schema.String, description: Schema.String,
}).pipe(withStatics((s) => ({ zod: zod(s) }))) }).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry> export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
export type Target = export type Target =
| { | {
@@ -35,7 +35,7 @@ export type Target =
headers?: HeadersInit headers?: HeadersInit
} }
export type WorkspaceAdapter = { export type WorkspaceAdaptor = {
name: string name: string
description: string description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
@@ -16,7 +16,7 @@ import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdapter } from "./adapters" import { getAdaptor } from "./adaptors"
import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema" import { WorkspaceID } from "./schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@@ -335,8 +335,8 @@ export const layer = Layer.effect(
}) })
const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) {
const adapter = getAdapter(space.projectID, space.type) const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") return if (target.type === "local") return
@@ -419,8 +419,8 @@ export const layer = Layer.effect(
const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) {
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return
const adapter = getAdapter(space.projectID, space.type) const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
if (target.type === "local") { if (target.type === "local") {
setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error")
@@ -458,9 +458,9 @@ export const layer = Layer.effect(
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id) const id = WorkspaceID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type) const adaptor = getAdaptor(input.projectID, input.type)
const config = yield* Effect.promise(() => const config = yield* Effect.promise(() =>
Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })), Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })),
) )
const info: Info = { const info: Info = {
@@ -496,7 +496,7 @@ export const layer = Layer.effect(
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
} }
yield* Effect.promise(() => adapter.create(config, env)) yield* Effect.promise(() => adaptor.create(config, env))
yield* Effect.all( yield* Effect.all(
[ [
waitEvent({ waitEvent({
@@ -531,8 +531,8 @@ export const layer = Layer.effect(
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
}) })
const adapter = getAdapter(space.projectID, space.type) const adaptor = getAdaptor(space.projectID, space.type)
const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space)))
yield* sync.run(Session.Event.Updated, { yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID, sessionID: input.sessionID,
@@ -726,12 +726,12 @@ export const layer = Layer.effect(
const info = fromRow(row) const info = fromRow(row)
yield* Effect.catch( yield* Effect.catch(
Effect.gen(function* () { Effect.gen(function* () {
const adapter = getAdapter(info.projectID, row.type) const adaptor = getAdaptor(info.projectID, row.type)
yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info))) yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info)))
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
log.error("adapter not available when removing workspace", { type: row.type }) log.error("adaptor not available when removing workspace", { type: row.type })
}), }),
) )
+5 -5
View File
@@ -3,7 +3,7 @@ import type {
PluginInput, PluginInput,
Plugin as PluginInstance, Plugin as PluginInstance,
PluginModule, PluginModule,
WorkspaceAdapter as PluginWorkspaceAdapter, WorkspaceAdaptor as PluginWorkspaceAdaptor,
} from "@opencode-ai/plugin" } from "@opencode-ai/plugin"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Bus } from "../bus" import { Bus } from "../bus"
@@ -24,8 +24,8 @@ import { InstanceState } from "@/effect/instance-state"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { PluginLoader } from "./loader" import { PluginLoader } from "./loader"
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
import { registerAdapter } from "@/control-plane/adapters" import { registerAdaptor } from "@/control-plane/adaptors"
import type { WorkspaceAdapter } from "@/control-plane/types" import type { WorkspaceAdaptor } from "@/control-plane/types"
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -138,8 +138,8 @@ export const layer = Layer.effect(
worktree: ctx.worktree, worktree: ctx.worktree,
directory: ctx.directory, directory: ctx.directory,
experimental_workspace: { experimental_workspace: {
register(type: string, adapter: PluginWorkspaceAdapter) { register(type: string, adaptor: PluginWorkspaceAdaptor) {
registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
}, },
}, },
get serverUrl(): URL { get serverUrl(): URL {
@@ -1,10 +0,0 @@
import { LocalContext } from "@/util/local-context"
import type * as Project from "./project"
export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
export const context = LocalContext.create<InstanceContext>("instance")
@@ -1,186 +0,0 @@
import { GlobalBus } from "@/bus/global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { context, type InstanceContext } from "./instance-context"
import * as Project from "./project"
export interface LoadInput {
directory: string
init?: () => Promise<unknown>
worktree?: string
project?: Project.Info
}
export interface Interface {
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext>
}
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
worktree: result.sandbox,
project: result.project,
})),
)
const init = input.init
if (init) yield* Effect.promise(() => context.provide(ctx, init))
return ctx
})
const removeEntry = (directory: string, entry: Entry) =>
Effect.sync(() => {
if (cache.get(directory) !== entry) return false
cache.delete(directory)
return true
})
const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) {
const exit = yield* Effect.exit(boot({ ...input, directory }))
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
})
const emitDisposed = (input: { directory: string; project?: string }) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: input.directory,
project: input.project,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory: input.directory,
},
},
}),
)
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
yield* Effect.promise(() => disposeInstance(ctx.directory))
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
})
const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) {
if (cache.get(directory) !== entry) return false
yield* disposeContext(ctx)
if (cache.get(directory) !== entry) return false
cache.delete(directory)
return true
})
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const existing = cache.get(directory)
if (existing) return yield* restore(Deferred.await(existing.deferred))
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("creating instance", { directory })
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
}),
)
})
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const previous = cache.get(directory)
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("reloading instance", { directory })
if (previous) {
yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
yield* Effect.promise(() => disposeInstance(directory))
yield* emitDisposed({ directory, project: input.project?.id })
}
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
}),
)
})
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
const entry = cache.get(ctx.directory)
if (!entry) return yield* disposeContext(ctx)
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid)
if (exit.value !== ctx) return
yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid)
})
const disposeAllOnce = Effect.fnUntraced(function* () {
yield* Effect.logInfo("disposing all instances")
yield* Effect.forEach(
[...cache.entries()],
(item) =>
Effect.gen(function* () {
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
yield* removeEntry(item[0], item[1])
return
}
yield* disposeEntry(item[0], item[1], exit.value)
}),
{ discard: true },
)
})
const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero)
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
return yield* cachedDisposeAll
})
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
return Service.of({
load,
reload,
dispose,
disposeAll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
export const runtime = makeRuntime(Service, defaultLayer)
export * as InstanceStore from "./instance-store"
+133 -14
View File
@@ -1,20 +1,77 @@
import { GlobalBus } from "@/bus/global"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { iife } from "@/util/iife"
import * as Log from "@opencode-ai/core/util/log"
import { LocalContext } from "@/util/local-context"
import * as Project from "./project" import * as Project from "./project"
import { context, type InstanceContext } from "./instance-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceStore } from "./instance-store"
export type { InstanceContext } from "./instance-context" export interface InstanceContext {
export type { LoadInput } from "./instance-store" directory: string
worktree: string
project: Project.Info
}
const context = LocalContext.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const project = makeRuntime(Project.Service, Project.defaultLayer)
const disposal = {
all: undefined as Promise<void> | undefined,
}
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await project
.runPromise((svc) => svc.fromDirectory(input.directory))
.then(({ project, sandbox }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
}
function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
export const Instance = { export const Instance = {
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
return InstanceStore.runtime.runPromise((store) => store.load(input))
},
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
return context.provide( const directory = AppFileSystem.resolve(input.directory)
await Instance.load({ directory: input.directory, init: input.init }), let existing = cache.get(directory)
async () => input.fn(), if (!existing) {
) Log.Default.info("creating instance", { directory })
existing = track(
directory,
boot({
directory,
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
return input.fn()
})
}, },
get current() { get current() {
return context.use() return context.use()
@@ -60,12 +117,74 @@ export const Instance = {
return context.provide(ctx, fn) return context.provide(ctx, fn)
}, },
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return InstanceStore.runtime.runPromise((store) => store.reload(input)) const directory = AppFileSystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
await disposeInstance(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next
}, },
async dispose() { async dispose() {
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await disposeInstance(directory)
cache.delete(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}, },
async disposeAll() { async disposeAll() {
return InstanceStore.runtime.runPromise((store) => store.disposeAll()) if (disposal.all) return disposal.all
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (cache.get(key) !== value) continue
await context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
return disposal.all
}, },
} }
@@ -2,10 +2,10 @@ import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { listAdapters } from "@/control-plane/adapters" import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { WorkspaceAdapterEntry } from "@/control-plane/types" import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { zodObject } from "@/util/effect-zod" import { zodObject } from "@/util/effect-zod"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { errors } from "../../error" import { errors } from "../../error"
@@ -18,24 +18,24 @@ const log = Log.create({ service: "server.workspace" })
export const WorkspaceRoutes = lazy(() => export const WorkspaceRoutes = lazy(() =>
new Hono() new Hono()
.get( .get(
"/adapter", "/adaptor",
describeRoute({ describeRoute({
summary: "List workspace adapters", summary: "List workspace adaptors",
description: "List all available workspace adapters for the current project.", description: "List all available workspace adaptors for the current project.",
operationId: "experimental.workspace.adapter.list", operationId: "experimental.workspace.adaptor.list",
responses: { responses: {
200: { 200: {
description: "Workspace adapters", description: "Workspace adaptors",
content: { content: {
"application/json": { "application/json": {
schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) => {
return c.json(await listAdapters(Instance.project.id)) return c.json(await listAdaptors(Instance.project.id))
}, },
) )
.post( .post(
@@ -1,5 +1,5 @@
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterEntry } from "@/control-plane/types" import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import { NonNegativeInt } from "@/util/schema" import { NonNegativeInt } from "@/util/schema"
import { Schema, Struct } from "effect" import { Schema, Struct } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -9,17 +9,14 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
import { described } from "./metadata" import { described } from "./metadata"
const root = "/experimental/workspace" const root = "/experimental/workspace"
export const CreatePayload = Schema.Struct({ export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
extra: Schema.optional(Workspace.CreateInput.fields.extra),
})
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
export const SessionRestoreResponse = Schema.Struct({ export const SessionRestoreResponse = Schema.Struct({
total: NonNegativeInt, total: NonNegativeInt,
}) })
export const WorkspacePaths = { export const WorkspacePaths = {
adapters: `${root}/adapter`, adaptors: `${root}/adaptor`,
list: root, list: root,
status: `${root}/status`, status: `${root}/status`,
remove: `${root}/:id`, remove: `${root}/:id`,
@@ -30,13 +27,13 @@ export const WorkspaceApi = HttpApi.make("workspace")
.add( .add(
HttpApiGroup.make("workspace") HttpApiGroup.make("workspace")
.add( .add(
HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, {
success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "experimental.workspace.adapter.list", identifier: "experimental.workspace.adaptor.list",
summary: "List workspace adapters", summary: "List workspace adaptors",
description: "List all available workspace adapters for the current project.", description: "List all available workspace adaptors for the current project.",
}), }),
), ),
HttpApiEndpoint.get("list", WorkspacePaths.list, { HttpApiEndpoint.get("list", WorkspacePaths.list, {
@@ -1,7 +1,7 @@
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { InstanceStore } from "@/project/instance-store" import { Instance } from "@/project/instance"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect" import { Effect, Queue, Schema } from "effect"
@@ -68,7 +68,6 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const installation = yield* Installation.Service const installation = yield* Installation.Service
const store = yield* InstanceStore.Service
const health = Effect.fn("GlobalHttpApi.health")(function* () { const health = Effect.fn("GlobalHttpApi.health")(function* () {
return { healthy: true as const, version: InstallationVersion } return { healthy: true as const, version: InstallationVersion }
@@ -87,7 +86,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
}) })
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
yield* store.disposeAll() yield* Effect.promise(() => Instance.disposeAll())
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: "global", directory: "global",
payload: { type: "global.disposed", properties: {} }, payload: { type: "global.disposed", properties: {} },
@@ -1,4 +1,4 @@
import { listAdapters } from "@/control-plane/adapters" import { listAdaptors } from "@/control-plane/adaptors"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state" import * as InstanceState from "@/effect/instance-state"
import { Effect } from "effect" import { Effect } from "effect"
@@ -10,9 +10,9 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
Effect.gen(function* () { Effect.gen(function* () {
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () { const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const instance = yield* InstanceState.context const instance = yield* InstanceState.context
return yield* Effect.promise(() => listAdapters(instance.project.id)) return yield* Effect.promise(() => listAdaptors(instance.project.id))
}) })
const list = Effect.fn("WorkspaceHttpApi.list")(function* () { const list = Effect.fn("WorkspaceHttpApi.list")(function* () {
@@ -24,7 +24,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
return yield* workspace return yield* workspace
.create({ .create({
...ctx.payload, ...ctx.payload,
extra: ctx.payload.extra ?? null,
projectID: instance.project.id, projectID: instance.project.id,
}) })
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
@@ -52,7 +51,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
}) })
return handlers return handlers
.handle("adapters", adapters) .handle("adaptors", adaptors)
.handle("list", list) .handle("list", list)
.handle("create", create) .handle("create", create)
.handle("status", status) .handle("status", status)
@@ -1,13 +1,13 @@
import { EffectBridge } from "@/effect/bridge" import type { WorkspaceID } from "@/control-plane/schema"
import type { InstanceContext } from "@/project/instance" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceStore } from "@/project/instance-store" import { WorkspaceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "@/project/instance"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
type MarkedInstance = { type MarkedInstance = {
ctx: InstanceContext ctx: InstanceContext
store: InstanceStore.Interface workspaceID?: WorkspaceID
bridge: EffectBridge.Shape
} }
// Disposal is requested by an endpoint handler, but must run from the outer // Disposal is requested by an endpoint handler, but must run from the outer
@@ -17,9 +17,20 @@ const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
const mark = (ctx: InstanceContext) => const mark = (ctx: InstanceContext) =>
Effect.gen(function* () { Effect.gen(function* () {
return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() } return { ctx, workspaceID: yield* WorkspaceRef }
}) })
// Instance.dispose/reload still publish events through legacy ALS helpers.
// Effect request handlers carry these values in services, so bridge them back
// into the legacy contexts only around the lifecycle operation.
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
Effect.promise(() =>
WorkspaceContext.provide({
workspaceID: marked.workspaceID,
fn: () => Instance.restore(marked.ctx, fn),
}),
)
export const markInstanceForDisposal = (ctx: InstanceContext) => export const markInstanceForDisposal = (ctx: InstanceContext) =>
Effect.gen(function* () { Effect.gen(function* () {
const marked = yield* mark(ctx) const marked = yield* mark(ctx)
@@ -32,11 +43,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
) )
}) })
export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) => export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
Effect.gen(function* () { Effect.gen(function* () {
const marked = yield* mark(ctx) const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((_request, response) => return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response), Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
) )
}) })
@@ -47,6 +58,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
const marked = disposeAfterResponse.get(request.source) const marked = disposeAfterResponse.get(request.source)
if (!marked) return response if (!marked) return response
disposeAfterResponse.delete(request.source) disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
return response return response
}) })
@@ -1,8 +1,9 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import type { InstanceContext } from "@/project/instance" import type { InstanceContext } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store" import { Filesystem } from "@/util/filesystem"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -23,23 +24,22 @@ function decode(input: string): string {
} }
} }
function makeInstanceContext( function makeInstanceContext(directory: string): Effect.Effect<InstanceContext> {
store: InstanceStore.Interface, return Effect.promise(() =>
directory: string, Instance.provide({
): Effect.Effect<InstanceContext> { directory: Filesystem.resolve(decode(directory)),
return store.load({ init: () => AppRuntime.runPromise(InstanceBootstrap),
directory: decode(directory), fn: () => Instance.current,
init: () => AppRuntime.runPromise(InstanceBootstrap), }),
}) )
} }
function provideInstanceContext<E>( function provideInstanceContext<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>, effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
store: InstanceStore.Interface,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> { ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () { return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext const route = yield* WorkspaceRouteContext
const ctx = yield* makeInstanceContext(store, route.directory) const ctx = yield* makeInstanceContext(route.directory)
return yield* effect.pipe( return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx), Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, route.workspaceID), Effect.provideService(WorkspaceRef, route.workspaceID),
@@ -47,17 +47,9 @@ function provideInstanceContext<E>(
}) })
} }
export const instanceContextLayer = Layer.effect( export const instanceContextLayer = Layer.succeed(
InstanceContextMiddleware, InstanceContextMiddleware,
Effect.gen(function* () { InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
const store = yield* InstanceStore.Service
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
}),
) )
export const instanceRouterMiddleware = HttpRouter.middleware()( export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect))
Effect.gen(function* () {
const store = yield* InstanceStore.Service
return (effect) => provideInstanceContext(effect, store)
}),
)
@@ -1,4 +1,4 @@
import { getAdapter } from "@/control-plane/adapters" import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceID } from "@/control-plane/schema"
import type { Target } from "@/control-plane/types" import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
@@ -89,8 +89,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe
function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> { function resolveTarget(workspace: Workspace.Info): Effect.Effect<Target> {
return Effect.gen(function* () { return Effect.gen(function* () {
const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type))
return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace)))
}) })
} }
@@ -17,7 +17,6 @@ import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth" import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
@@ -146,7 +145,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
Format.defaultLayer, Format.defaultLayer,
LSP.defaultLayer, LSP.defaultLayer,
Installation.defaultLayer, Installation.defaultLayer,
InstanceStore.defaultLayer,
MCP.defaultLayer, MCP.defaultLayer,
Permission.defaultLayer, Permission.defaultLayer,
Project.defaultLayer, Project.defaultLayer,
+3 -3
View File
@@ -1,6 +1,6 @@
import type { MiddlewareHandler } from "hono" import type { MiddlewareHandler } from "hono"
import type { UpgradeWebSocket } from "hono/ws" import type { UpgradeWebSocket } from "hono/ws"
import { getAdapter } from "@/control-plane/adapters" import { getAdaptor } from "@/control-plane/adaptors"
import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
@@ -91,8 +91,8 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return next() return next()
} }
const adapter = getAdapter(workspace.projectID, workspace.type) const adaptor = getAdaptor(workspace.projectID, workspace.type)
const target = await adapter.target(workspace) const target = await adaptor.target(workspace)
if (target.type === "local") { if (target.type === "local") {
return WorkspaceContext.provide({ return WorkspaceContext.provide({
+2 -10
View File
@@ -772,7 +772,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
return { return {
type: "content", type: "content",
value: [ value: [
...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), { type: "text", text: outputObject.text },
...attachments.map((attachment) => ({ ...attachments.map((attachment) => ({
type: "media", type: "media",
mediaType: attachment.mime, mediaType: attachment.mime,
@@ -938,18 +938,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
}) })
} }
if (part.type === "reasoning") { if (part.type === "reasoning") {
if (differentModel) {
if (part.text.trim().length > 0)
assistantMessage.parts.push({
type: "text",
text: part.text,
})
continue
}
assistantMessage.parts.push({ assistantMessage.parts.push({
type: "reasoning", type: "reasoning",
text: part.text, text: part.text,
providerMetadata: part.metadata, ...(differentModel ? {} : { providerMetadata: part.metadata }),
}) })
} }
} }
+3 -3
View File
@@ -142,9 +142,9 @@ const Share = Schema.Struct({
url: Schema.String, url: Schema.String,
}) })
// Legacy HTTP accepted negative values here. Keep archive timestamps permissive // Legacy HTTP accepted any number here, and persisted data may already contain
// while excluding non-finite values that cannot round-trip through JSON. // negative values. Keep archive timestamps permissive while other clocks stay non-negative.
export const ArchivedTimestamp = Schema.Finite export const ArchivedTimestamp = Schema.Number
const Time = Schema.Struct({ const Time = Schema.Struct({
created: NonNegativeInt, created: NonNegativeInt,
+3 -3
View File
@@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes
### Event shape ### Event shape
- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus. - The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus.
The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types.
@@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini
This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat.
The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this). The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this).
It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples:
```ts ```ts
// The schema from `Updated` typechecks the object correctly // The schema from `Updated` typechecks the object correctly
+1 -19
View File
@@ -5,7 +5,6 @@ import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Global } from "@opencode-ai/core/global"
// Helper to evaluate permission for a tool with wildcard pattern // Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
@@ -84,7 +83,7 @@ test("explore agent denies edit and write", async () => {
}) })
}) })
test("explore agent asks for external directories and allows whitelisted external paths", async () => { test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
@@ -94,9 +93,6 @@ test("explore agent asks for external directories and allows whitelisted externa
expect(explore).toBeDefined() expect(explore).toBeDefined()
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action,
).toBe("allow")
}, },
}) })
}) })
@@ -519,20 +515,6 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
}) })
}) })
test("global tmp directory children are allowed for external_directory", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
expect(
Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action,
).toBe("allow")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask")
},
})
})
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncate")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import type { WorkspaceInfo } from "../../src/control-plane/types" import type { WorkspaceInfo } from "../../src/control-plane/types"
@@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf
} }
} }
function adapter(dir: string) { function adaptor(dir: string) {
return { return {
name: dir, name: dir,
description: dir, description: dir,
@@ -33,19 +33,19 @@ function adapter(dir: string) {
} }
} }
describe("control-plane/adapters", () => { describe("control-plane/adaptors", () => {
test("isolates custom adapters by project", async () => { test("isolates custom adaptors by project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}` const type = `demo-${Math.random().toString(36).slice(2)}`
const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(one, type, adapter("/one")) registerAdaptor(one, type, adaptor("/one"))
registerAdapter(two, type, adapter("/two")) registerAdaptor(two, type, adaptor("/two"))
expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({ expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({
type: "local", type: "local",
directory: "/one", directory: "/one",
}) })
expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({ expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({
type: "local", type: "local",
directory: "/two", directory: "/two",
}) })
@@ -54,16 +54,16 @@ describe("control-plane/adapters", () => {
test("latest install wins within a project", async () => { test("latest install wins within a project", async () => {
const type = `demo-${Math.random().toString(36).slice(2)}` const type = `demo-${Math.random().toString(36).slice(2)}`
const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`)
registerAdapter(id, type, adapter("/one")) registerAdaptor(id, type, adaptor("/one"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local", type: "local",
directory: "/one", directory: "/one",
}) })
registerAdapter(id, type, adapter("/two")) registerAdaptor(id, type, adaptor("/two"))
expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({
type: "local", type: "local",
directory: "/two", directory: "/two",
}) })
@@ -23,10 +23,10 @@ import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdaptor } from "../../src/control-plane/adaptors"
import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceID } from "../../src/control-plane/schema"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types"
import * as WorkspaceOld from "../../src/control-plane/workspace" import * as WorkspaceOld from "../../src/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
@@ -53,8 +53,8 @@ type RecordedCreate = {
from?: WorkspaceInfo from?: WorkspaceInfo
} }
type RecordedAdapter = { type RecordedAdaptor = {
adapter: WorkspaceAdapter adaptor: WorkspaceAdaptor
calls: { calls: {
configure: WorkspaceInfo[] configure: WorkspaceInfo[]
create: RecordedCreate[] create: RecordedCreate[]
@@ -165,13 +165,13 @@ function eventuallyEffect(effect: Effect.Effect<void>, timeout = 1500) {
}) })
} }
function recordedAdapter(input: { function recordedAdaptor(input: {
target: (info: WorkspaceInfo) => Target | Promise<Target> target: (info: WorkspaceInfo) => Target | Promise<Target>
configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo> configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise<WorkspaceInfo>
create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void> create?: (info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo) => Promise<void>
remove?: (info: WorkspaceInfo) => Promise<void> remove?: (info: WorkspaceInfo) => Promise<void>
}): RecordedAdapter { }): RecordedAdaptor {
const calls: RecordedAdapter["calls"] = { const calls: RecordedAdaptor["calls"] = {
configure: [], configure: [],
create: [], create: [],
remove: [], remove: [],
@@ -180,7 +180,7 @@ function recordedAdapter(input: {
return { return {
calls, calls,
adapter: { adaptor: {
name: "recorded", name: "recorded",
description: "recorded", description: "recorded",
configure(info) { configure(info) {
@@ -207,8 +207,8 @@ function recordedAdapter(input: {
} }
} }
function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) { function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise<void> }) {
return recordedAdapter({ return recordedAdaptor({
configure(info) { configure(info) {
return { ...info, directory: dir } return { ...info, directory: dir }
}, },
@@ -223,8 +223,8 @@ function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info
}) })
} }
function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) {
return recordedAdapter({ return recordedAdaptor({
configure(info) { configure(info) {
return { ...info, directory: input?.directory ?? info.directory } return { ...info, directory: input?.directory ?? info.directory }
}, },
@@ -429,7 +429,7 @@ describe("workspace-old CRUD", () => {
const workspaceID = WorkspaceID.ascending("wrk_create_local") const workspaceID = WorkspaceID.ascending("wrk_create_local")
const type = unique("create-local") const type = unique("create-local")
const targetDir = path.join(dir, "created-local") const targetDir = path.join(dir, "created-local")
const recorded = recordedAdapter({ const recorded = recordedAdaptor({
configure(info) { configure(info) {
return { return {
...info, ...info,
@@ -446,7 +446,7 @@ describe("workspace-old CRUD", () => {
return { type: "local", directory: targetDir } return { type: "local", directory: targetDir }
}, },
}) })
registerAdapter(Instance.project.id, type, recorded.adapter) registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ const info = await createWorkspace({
id: workspaceID, id: workspaceID,
@@ -489,17 +489,17 @@ describe("workspace-old CRUD", () => {
test("create propagates configure failures and does not insert a workspace", async () => { test("create propagates configure failures and does not insert a workspace", async () => {
await withInstance(async () => { await withInstance(async () => {
const type = unique("configure-failure") const type = unique("configure-failure")
registerAdapter( registerAdaptor(
Instance.project.id, Instance.project.id,
type, type,
recordedAdapter({ recordedAdaptor({
configure() { configure() {
throw new Error("configure exploded") throw new Error("configure exploded")
}, },
target() { target() {
return { type: "local", directory: "/unused" } return { type: "local", directory: "/unused" }
}, },
}).adapter, }).adaptor,
) )
await expect( await expect(
@@ -509,10 +509,10 @@ describe("workspace-old CRUD", () => {
}) })
}) })
test("create leaves the inserted row when adapter create fails", async () => { test("create leaves the inserted row when adaptor create fails", async () => {
await withInstance(async () => { await withInstance(async () => {
const type = unique("create-failure") const type = unique("create-failure")
const recorded = recordedAdapter({ const recorded = recordedAdaptor({
async create() { async create() {
throw new Error("create exploded") throw new Error("create exploded")
}, },
@@ -520,7 +520,7 @@ describe("workspace-old CRUD", () => {
return { type: "local", directory: "/unused" } return { type: "local", directory: "/unused" }
}, },
}) })
registerAdapter(Instance.project.id, type, recorded.adapter) registerAdaptor(Instance.project.id, type, recorded.adaptor)
await expect( await expect(
createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }),
@@ -538,8 +538,8 @@ describe("workspace-old CRUD", () => {
await withInstance(async (dir) => { await withInstance(async (dir) => {
const type = unique("local-error") const type = unique("local-error")
const missing = path.join(dir, "missing-local-target") const missing = path.join(dir, "missing-local-target")
const recorded = localAdapter(missing, { createDir: false }) const recorded = localAdaptor(missing, { createDir: false })
registerAdapter(Instance.project.id, type, recorded.adapter) registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
@@ -576,8 +576,8 @@ describe("workspace-old CRUD", () => {
Effect.gen(function* () { Effect.gen(function* () {
const workspace = yield* WorkspaceOld.Service const workspace = yield* WorkspaceOld.Service
const type = unique("remote-create") const type = unique("remote-create")
const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir })
registerAdapter(Instance.project.id, type, recorded.adapter) registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null })
@@ -603,11 +603,11 @@ describe("workspace-old CRUD", () => {
}) })
}) })
test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => { test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => {
await withInstance(async (dir) => { await withInstance(async (dir) => {
const type = unique("remove-local") const type = unique("remove-local")
const recorded = localAdapter(path.join(dir, "remove-local")) const recorded = localAdaptor(path.join(dir, "remove-local"))
registerAdapter(Instance.project.id, type, recorded.adapter) registerAdaptor(Instance.project.id, type, recorded.adaptor)
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null })
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
@@ -628,21 +628,21 @@ describe("workspace-old CRUD", () => {
}) })
}) })
test("remove still deletes the row when the adapter cannot remove resources", async () => { test("remove still deletes the row when the adaptor cannot remove resources", async () => {
await withInstance(async () => { await withInstance(async () => {
const type = unique("remove-throws") const type = unique("remove-throws")
const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") })
registerAdapter( registerAdaptor(
Instance.project.id, Instance.project.id,
type, type,
recordedAdapter({ recordedAdaptor({
async remove() { async remove() {
throw new Error("remove exploded") throw new Error("remove exploded")
}, },
target() { target() {
return { type: "local", directory: "/unused" } return { type: "local", directory: "/unused" }
}, },
}).adapter, }).adaptor,
) )
insertWorkspace(info) insertWorkspace(info)
@@ -661,7 +661,7 @@ describe("workspace-old sync state", () => {
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))
attachSessionToWorkspace(session.id, info.id) attachSessionToWorkspace(session.id, info.id)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor)
startWorkspaceSyncing(Instance.project.id) startWorkspaceSyncing(Instance.project.id)
await delay(25) await delay(25)
@@ -682,8 +682,8 @@ describe("workspace-old sync state", () => {
await fs.mkdir(withoutSessionDir, { recursive: true }) await fs.mkdir(withoutSessionDir, { recursive: true })
insertWorkspace(withSession) insertWorkspace(withSession)
insertWorkspace(withoutSession) insertWorkspace(withoutSession)
registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor)
registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor)
attachSessionToWorkspace( attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
withSession.id, withSession.id,
@@ -707,10 +707,10 @@ describe("workspace-old sync state", () => {
const type = unique("missing-local") const type = unique("missing-local")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter( registerAdaptor(
Instance.project.id, Instance.project.id,
type, type,
localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter, localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor,
) )
attachSessionToWorkspace( attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
@@ -738,7 +738,7 @@ describe("workspace-old sync state", () => {
const target = path.join(dir, "dedupe-local") const target = path.join(dir, "dedupe-local")
await fs.mkdir(target, { recursive: true }) await fs.mkdir(target, { recursive: true })
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(target).adapter) registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor)
attachSessionToWorkspace( attachSessionToWorkspace(
(await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id,
info.id, info.id,
@@ -795,7 +795,7 @@ describe("workspace-old sync state", () => {
const type = unique("remote-start") const type = unique("remote-start")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id) yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -850,7 +850,7 @@ describe("workspace-old sync state", () => {
const type = unique("remote-connect-fail") const type = unique("remote-connect-fail")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id) yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -890,7 +890,7 @@ describe("workspace-old sync state", () => {
const type = unique("remote-history-fail") const type = unique("remote-history-fail")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id) yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -947,7 +947,7 @@ describe("workspace-old sync state", () => {
const type = unique("history-replay") const type = unique("history-replay")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor)
const session = yield* sessionSvc.create({ title: "before history" }) const session = yield* sessionSvc.create({ title: "before history" })
attachSessionToWorkspace(session.id, info.id) attachSessionToWorkspace(session.id, info.id)
historySessionID = session.id historySessionID = session.id
@@ -1014,7 +1014,7 @@ describe("workspace-old sync state", () => {
const type = unique("sse-forward") const type = unique("sse-forward")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor)
attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id)
yield* workspace.startWorkspaceSyncing(Instance.project.id) yield* workspace.startWorkspaceSyncing(Instance.project.id)
@@ -1095,7 +1095,7 @@ describe("workspace-old sync state", () => {
const type = unique("sse-sync") const type = unique("sse-sync")
const info = workspaceInfo(Instance.project.id, type) const info = workspaceInfo(Instance.project.id, type)
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor)
const session = yield* sessionSvc.create({ title: "before sse" }) const session = yield* sessionSvc.create({ title: "before sse" })
attachSessionToWorkspace(session.id, info.id) attachSessionToWorkspace(session.id, info.id)
sseSessionID = session.id sseSessionID = session.id
@@ -1232,7 +1232,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-missing-session") const type = unique("restore-missing-session")
const info = workspaceInfo(Instance.project.id, type, { directory: dir }) const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
await expect( await expect(
restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }),
@@ -1273,13 +1273,13 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-remote") const type = unique("restore-remote")
const info = workspaceInfo(Instance.project.id, type, { directory: dir }) const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info) insertWorkspace(info)
registerAdapter( registerAdaptor(
Instance.project.id, Instance.project.id,
type, type,
remoteAdapter(`${url}/restore/?ignored=1#hash`, { remoteAdaptor(`${url}/restore/?ignored=1#hash`, {
directory: dir, directory: dir,
headers: { authorization: "Bearer restore" }, headers: { authorization: "Bearer restore" },
}).adapter, }).adaptor,
) )
const session = yield* sessionSvc.create({ title: "restore remote" }) const session = yield* sessionSvc.create({ title: "restore remote" })
replaceSessionEvents(session.id, 24) replaceSessionEvents(session.id, 24)
@@ -1353,7 +1353,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-null-dir") const type = unique("restore-null-dir")
const info = workspaceInfo(Instance.project.id, type, { directory: null }) const info = workspaceInfo(Instance.project.id, type, { directory: null })
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor)
const session = yield* sessionSvc.create({ title: "null dir" }) const session = yield* sessionSvc.create({ title: "null dir" })
replaceSessionEvents(session.id, 0) replaceSessionEvents(session.id, 0)
@@ -1397,7 +1397,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-remote-fail") const type = unique("restore-remote-fail")
const info = workspaceInfo(Instance.project.id, type, { directory: dir }) const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "restore fail" }) const session = yield* sessionSvc.create({ title: "restore fail" })
replaceSessionEvents(session.id, 11) replaceSessionEvents(session.id, 11)
@@ -1437,7 +1437,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-local") const type = unique("restore-local")
const info = workspaceInfo(Instance.project.id, type, { directory: dir }) const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor)
const session = yield* sessionSvc.create({ title: "restore local" }) const session = yield* sessionSvc.create({ title: "restore local" })
replaceSessionEvents(session.id, 20) replaceSessionEvents(session.id, 20)
@@ -1488,7 +1488,7 @@ describe("workspace-old sessionRestore", () => {
const type = unique("restore-real-events") const type = unique("restore-real-events")
const info = workspaceInfo(Instance.project.id, type, { directory: dir }) const info = workspaceInfo(Instance.project.id, type, { directory: dir })
insertWorkspace(info) insertWorkspace(info)
registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor)
const session = yield* sessionSvc.create({ title: "real events" }) const session = yield* sessionSvc.create({ title: "real events" })
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const msg = yield* sessionSvc.updateMessage({ const msg = yield* sessionSvc.updateMessage({
@@ -34,7 +34,7 @@ afterAll(() => {
}) })
describe("plugin.workspace", () => { describe("plugin.workspace", () => {
it.live("plugin can install a workspace adapter", () => it.live("plugin can install a workspace adaptor", () =>
provideTmpdirInstance((dir) => provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const type = `plug-${Math.random().toString(36).slice(2)}` const type = `plug-${Math.random().toString(36).slice(2)}`
@@ -48,7 +48,7 @@ describe("plugin.workspace", () => {
"export default async ({ experimental_workspace }) => {", "export default async ({ experimental_workspace }) => {",
` experimental_workspace.register(${JSON.stringify(type)}, {`, ` experimental_workspace.register(${JSON.stringify(type)}, {`,
' name: "plug",', ' name: "plug",',
' description: "plugin workspace adapter",', ' description: "plugin workspace adaptor",',
" configure(input) {", " configure(input) {",
` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`,
" },", " },",
@@ -1,254 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Fiber, Layer } from "effect"
import { registerDisposer } from "../../src/effect/instance-registry"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
afterEach(async () => {
await Instance.disposeAll()
})
describe("InstanceStore", () => {
it.live("loads instance context without installing ALS for the caller", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const ctx = yield* store.load({ directory: dir })
expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("runs load init inside the loaded legacy instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let initializedDirectory: string | undefined
yield* store.load({
directory: dir,
init: async () => {
initializedDirectory = Instance.directory
},
})
expect(initializedDirectory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("caches loaded instance context by directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let initialized = 0
const first = yield* store.load({
directory: dir,
init: async () => {
initialized++
},
})
const second = yield* store.load({
directory: dir,
init: async () => {
initialized++
},
})
expect(second).toBe(first)
expect(initialized).toBe(1)
}),
)
it.live("dedupes concurrent loads while init is in flight", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let initialized = 0
const first = yield* store
.load({
directory: dir,
init: async () => {
initialized++
started.resolve()
await release.promise
},
})
.pipe(Effect.forkScoped)
yield* Effect.promise(() => started.promise)
const second = yield* store
.load({
directory: dir,
init: async () => {
initialized++
},
})
.pipe(Effect.forkScoped)
expect(initialized).toBe(1)
release.resolve()
const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(secondCtx).toBe(firstCtx)
expect(initialized).toBe(1)
}),
)
it.live("removes failed loads from the cache", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
let attempts = 0
const failed = yield* store
.load({
directory: dir,
init: async () => {
attempts++
throw new Error("init failed")
},
})
.pipe(
Effect.as(false),
Effect.catchCause(() => Effect.succeed(true)),
)
expect(failed).toBe(true)
const ctx = yield* store.load({
directory: dir,
init: async () => {
attempts++
},
})
expect(ctx.directory).toBe(dir)
expect(attempts).toBe(2)
}),
)
it.live("reload replaces the cached context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const first = yield* store.load({ directory: dir })
const second = yield* store.reload({ directory: dir })
const cached = yield* store.load({ directory: dir })
expect(second).not.toBe(first)
expect(cached).toBe(second)
}),
)
it.live("stale dispose does not delete an in-flight reload", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const reloading = Promise.withResolvers<void>()
const releaseReload = Promise.withResolvers<void>()
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
})
yield* Effect.addFinalizer(() => Effect.sync(off))
const first = yield* store.load({ directory: dir })
const reload = yield* store
.reload({
directory: dir,
init: async () => {
reloading.resolve()
await releaseReload.promise
},
})
.pipe(Effect.forkScoped)
yield* Effect.promise(() => reloading.promise)
const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
releaseReload.resolve()
const second = yield* Fiber.join(reload)
yield* Fiber.join(staleDispose)
expect(disposed).toEqual([dir])
expect(yield* store.load({ directory: dir })).toBe(second)
}),
)
it.live("dedupes concurrent disposeAll calls", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposing = Promise.withResolvers<void>()
const releaseDispose = Promise.withResolvers<void>()
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
disposing.resolve()
await releaseDispose.promise
})
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* store.load({ directory: dir })
const first = yield* store.disposeAll().pipe(Effect.forkScoped)
yield* Effect.promise(() => disposing.promise)
const second = yield* store.disposeAll().pipe(Effect.forkScoped)
expect(disposed).toEqual([dir])
releaseDispose.resolve()
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
expect(disposed).toEqual([dir])
}),
)
it.live("re-arms disposeAll after completion", () =>
Effect.gen(function* () {
const dir1 = yield* tmpdirScoped({ git: true })
const dir2 = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore.Service
const disposed: Array<string> = []
const off = registerDisposer(async (directory) => {
disposed.push(directory)
})
yield* Effect.addFinalizer(() => Effect.sync(off))
yield* store.load({ directory: dir1 })
yield* store.disposeAll()
expect(disposed).toEqual([dir1])
yield* store.load({ directory: dir2 })
yield* store.disposeAll()
expect(disposed).toEqual([dir1, dir2])
}),
)
it.live("keeps Instance.provide as the legacy ALS wrapper", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const directory = yield* Effect.promise(() =>
Instance.provide({
directory: dir,
fn: () => Instance.directory,
}),
)
expect(directory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
})
@@ -258,18 +258,6 @@ describe("HttpApi server", () => {
}) })
}) })
test("matches SDK-affecting request schema details", () => {
const effect = effectOpenApi()
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
const sessionUpdateSchema =
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
? sessionUpdate.content?.["application/json"]?.schema
: undefined
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
const time = sessionUpdateProperties?.time
expect(time?.properties?.archived).toEqual({ type: "number" })
})
test("documents event routes as server-sent events", () => { test("documents event routes as server-sent events", () => {
const effect = effectOpenApi() const effect = effectOpenApi()
@@ -7,12 +7,11 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e
import * as Socket from "effect/unstable/socket/Socket" import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdapter } from "../../src/control-plane/types" import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -41,7 +40,6 @@ const it = testEffect(
testStateLayer, testStateLayer,
NodeHttpServer.layerTest, NodeHttpServer.layerTest,
NodeServices.layer, NodeServices.layer,
InstanceStore.defaultLayer,
Project.defaultLayer, Project.defaultLayer,
Workspace.defaultLayer, Workspace.defaultLayer,
), ),
@@ -51,7 +49,7 @@ const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware) .combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const localAdapter = (directory: string): WorkspaceAdapter => ({ const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test", name: "Local Test",
description: "Create a local test workspace", description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }), configure: (info) => ({ ...info, name: "local-test", directory }),
@@ -65,7 +63,7 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.gen(function* () { Effect.gen(function* () {
registerAdapter(input.projectID, input.type, localAdapter(input.directory)) registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
return yield* workspace.create({ return yield* workspace.create({
type: input.type, type: input.type,
@@ -3,8 +3,8 @@ import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Effect } from "effect" import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdapter } from "../../src/control-plane/types" import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
@@ -82,7 +82,7 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string
) )
} }
const localAdapter = (directory: string): WorkspaceAdapter => ({ const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test", name: "Local Test",
description: "Create a local test workspace", description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }), configure: (info) => ({ ...info, name: "local-test", directory }),
@@ -95,7 +95,7 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
Effect.gen(function* () { Effect.gen(function* () {
registerAdapter(input.projectID, input.type, localAdapter(input.directory)) registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
return yield* Workspace.Service.use((svc) => return yield* Workspace.Service.use((svc) =>
svc.create({ svc.create({
type: input.type, type: input.type,
@@ -15,9 +15,9 @@ import * as Socket from "effect/unstable/socket/Socket"
import Http from "node:http" import Http from "node:http"
import { mkdir } from "node:fs/promises" import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdaptor } from "../../src/control-plane/adaptors"
import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types" import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
@@ -82,7 +82,7 @@ const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
return HttpServer.formatAddress(server.address) return HttpServer.formatAddress(server.address)
}) })
const localAdapter = (directory: string): WorkspaceAdapter => ({ const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test", name: "Local Test",
description: "Create a local test workspace", description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }), configure: (info) => ({ ...info, name: "local-test", directory }),
@@ -93,7 +93,7 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
target: () => ({ type: "local" as const, directory }), target: () => ({ type: "local" as const, directory }),
}) })
const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({ const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({
name: "Remote Test", name: "Remote Test",
description: "Create a remote test workspace", description: "Create a remote test workspace",
configure: (info) => ({ ...info, name: "remote-test", directory }), configure: (info) => ({ ...info, name: "remote-test", directory }),
@@ -116,10 +116,10 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
return undefined return undefined
} }
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) => const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.gen(function* () { Effect.gen(function* () {
registerAdapter(input.projectID, input.type, input.adapter) registerAdaptor(input.projectID, input.type, input.adaptor)
const workspace = yield* Workspace.Service const workspace = yield* Workspace.Service
return yield* workspace.create({ return yield* workspace.create({
type: input.type, type: input.type,
@@ -144,14 +144,14 @@ const createRemoteWorkspace = (input: {
createWorkspace({ createWorkspace({
projectID: input.projectID, projectID: input.projectID,
type: input.type, type: input.type,
adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers), adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers),
}) })
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
createWorkspace({ createWorkspace({
projectID: input.projectID, projectID: input.projectID,
type: input.type, type: input.type,
adapter: localAdapter(input.directory), adaptor: localAdaptor(input.directory),
}) })
const insertRemoteWorkspaceWithoutSync = (input: { const insertRemoteWorkspaceWithoutSync = (input: {
@@ -162,7 +162,7 @@ const insertRemoteWorkspaceWithoutSync = (input: {
}) => }) =>
Effect.sync(() => { Effect.sync(() => {
const id = WorkspaceID.ascending() const id = WorkspaceID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url))
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
return id return id
}) })
@@ -237,7 +237,7 @@ describe("HttpApi workspace routing middleware", () => {
{ status: 201, headers: { "x-remote": "yes" } }, { status: 201, headers: { "x-remote": "yes" } },
) )
}) })
// The adapter target tells the middleware where to proxy selected remote // The adaptor target tells the middleware where to proxy selected remote
// workspace requests. Appending /probe to this base should produce // workspace requests. Appending /probe to this base should produce
// `${remoteUrl}/base/probe` on the fake remote server above. // `${remoteUrl}/base/probe` on the fake remote server above.
const workspace = yield* createRemoteWorkspace({ const workspace = yield* createRemoteWorkspace({
@@ -4,8 +4,8 @@ import { mkdir } from "node:fs/promises"
import path from "node:path" import path from "node:path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdapter } from "../../src/control-plane/types" import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { Session } from "@/session/session" import { Session } from "@/session/session"
@@ -27,16 +27,16 @@ const it = testEffect(
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
) )
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { function request(path: string, directory: string, init: RequestInit = {}) {
return Effect.promise(() => { return Effect.promise(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory) headers.set("x-opencode-directory", directory)
return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
}) })
} }
function localAdapter(directory: string): WorkspaceAdapter { function localAdaptor(directory: string): WorkspaceAdaptor {
return { return {
name: "Local Test", name: "Local Test",
description: "Create a local test workspace", description: "Create a local test workspace",
@@ -60,7 +60,7 @@ function localAdapter(directory: string): WorkspaceAdapter {
} }
} }
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor {
return { return {
name: "Remote Test", name: "Remote Test",
description: "Create a remote test workspace", description: "Create a remote test workspace",
@@ -137,14 +137,14 @@ describe("workspace HttpApi", () => {
Effect.gen(function* () { Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true }) const dir = yield* tmpdirScoped({ git: true })
const [adapters, workspaces, status] = yield* Effect.all([ const [adaptors, workspaces, status] = yield* Effect.all([
request(WorkspacePaths.adapters, dir), request(WorkspacePaths.adaptors, dir),
request(WorkspacePaths.list, dir), request(WorkspacePaths.list, dir),
request(WorkspacePaths.status, dir), request(WorkspacePaths.status, dir),
]) ])
expect(adapters.status).toBe(200) expect(adaptors.status).toBe(200)
expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({
type: "worktree", type: "worktree",
name: "Worktree", name: "Worktree",
description: "Create a git worktree", description: "Create a git worktree",
@@ -163,7 +163,7 @@ describe("workspace HttpApi", () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true }) const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir) const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) registerAdaptor(project.project.id, "local-test", localAdaptor(path.join(dir, ".workspace")))
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
@@ -195,62 +195,13 @@ describe("workspace HttpApi", () => {
}), }),
) )
it.live("creates workspace with the TUI payload shape", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null }),
})
expect(created.status).toBe(200)
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
it.live("documents legacy Hono accepting the TUI payload shape", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
const created = yield* request(
WorkspacePaths.list,
dir,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null }),
},
false,
)
expect(created.status).toBe(200)
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
it.live("routes local workspace requests through the workspace target directory", () => it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () { Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true }) const dir = yield* tmpdirScoped({ git: true })
const workspaceDir = path.join(dir, ".workspace-local") const workspaceDir = path.join(dir, ".workspace-local")
const project = yield* Project.use.fromDirectory(dir) const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir)) registerAdaptor(project.project.id, "local-target", localAdaptor(workspaceDir))
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -299,10 +250,10 @@ describe("workspace HttpApi", () => {
}) })
const project = yield* Project.use.fromDirectory(dir) const project = yield* Project.use.fromDirectory(dir)
registerAdapter( registerAdaptor(
project.project.id, project.project.id,
"remote-target", "remote-target",
remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
"x-target-auth": "secret", "x-target-auth": "secret",
}), }),
) )
@@ -368,10 +319,10 @@ describe("workspace HttpApi", () => {
}) })
const project = yield* Project.use.fromDirectory(dir) const project = yield* Project.use.fromDirectory(dir)
registerAdapter( registerAdaptor(
project.project.id, project.project.id,
"remote-session-target", "remote-session-target",
remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
) )
const created = yield* request(WorkspacePaths.list, dir, { const created = yield* request(WorkspacePaths.list, dir, {
method: "POST", method: "POST",
@@ -469,13 +469,6 @@ describe("session.message-v2.toModelMessage", () => {
}, },
{ {
...basePart(assistantID, "a2"), ...basePart(assistantID, "a2"),
type: "reasoning",
text: "thinking",
metadata: { openai: { reasoning: "meta" } },
time: { start: 0 },
},
{
...basePart(assistantID, "a3"),
type: "tool", type: "tool",
callID: "call-1", callID: "call-1",
tool: "bash", tool: "bash",
@@ -502,7 +495,6 @@ describe("session.message-v2.toModelMessage", () => {
role: "assistant", role: "assistant",
content: [ content: [
{ type: "text", text: "done" }, { type: "text", text: "done" },
{ type: "text", text: "thinking" },
{ {
type: "tool-call", type: "tool-call",
toolCallId: "call-1", toolCallId: "call-1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+2 -2
View File
@@ -45,7 +45,7 @@ export type WorkspaceTarget =
headers?: HeadersInit headers?: HeadersInit
} }
export type WorkspaceAdapter = { export type WorkspaceAdaptor = {
name: string name: string
description: string description: string
configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo> configure(config: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
@@ -60,7 +60,7 @@ export type PluginInput = {
directory: string directory: string
worktree: string worktree: string
experimental_workspace: { experimental_workspace: {
register(type: string, adapter: WorkspaceAdapter): void register(type: string, adaptor: WorkspaceAdaptor): void
} }
serverUrl: URL serverUrl: URL
$: BunShell $: BunShell
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+9 -9
View File
@@ -29,7 +29,7 @@ import type {
ExperimentalConsoleSwitchOrgResponses, ExperimentalConsoleSwitchOrgResponses,
ExperimentalResourceListResponses, ExperimentalResourceListResponses,
ExperimentalSessionListResponses, ExperimentalSessionListResponses,
ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceAdaptorListResponses,
ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateErrors,
ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceCreateResponses,
ExperimentalWorkspaceListResponses, ExperimentalWorkspaceListResponses,
@@ -512,11 +512,11 @@ export class App extends HeyApiClient {
} }
} }
export class Adapter extends HeyApiClient { export class Adaptor extends HeyApiClient {
/** /**
* List workspace adapters * List workspace adaptors
* *
* List all available workspace adapters for the current project. * List all available workspace adaptors for the current project.
*/ */
public list<ThrowOnError extends boolean = false>( public list<ThrowOnError extends boolean = false>(
parameters?: { parameters?: {
@@ -536,8 +536,8 @@ export class Adapter extends HeyApiClient {
}, },
], ],
) )
return (options?.client ?? this.client).get<ExperimentalWorkspaceAdapterListResponses, unknown, ThrowOnError>({ return (options?.client ?? this.client).get<ExperimentalWorkspaceAdaptorListResponses, unknown, ThrowOnError>({
url: "/experimental/workspace/adapter", url: "/experimental/workspace/adaptor",
...options, ...options,
...params, ...params,
}) })
@@ -731,9 +731,9 @@ export class Workspace extends HeyApiClient {
}) })
} }
private _adapter?: Adapter private _adaptor?: Adaptor
get adapter(): Adapter { get adaptor(): Adaptor {
return (this._adapter ??= new Adapter({ client: this.client })) return (this._adaptor ??= new Adaptor({ client: this.client }))
} }
} }
+6 -6
View File
@@ -2430,19 +2430,19 @@ export type AppLogResponses = {
export type AppLogResponse = AppLogResponses[keyof AppLogResponses] export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
export type ExperimentalWorkspaceAdapterListData = { export type ExperimentalWorkspaceAdaptorListData = {
body?: never body?: never
path?: never path?: never
query?: { query?: {
directory?: string directory?: string
workspace?: string workspace?: string
} }
url: "/experimental/workspace/adapter" url: "/experimental/workspace/adaptor"
} }
export type ExperimentalWorkspaceAdapterListResponses = { export type ExperimentalWorkspaceAdaptorListResponses = {
/** /**
* Workspace adapters * Workspace adaptors
*/ */
200: Array<{ 200: Array<{
type: string type: string
@@ -2451,8 +2451,8 @@ export type ExperimentalWorkspaceAdapterListResponses = {
}> }>
} }
export type ExperimentalWorkspaceAdapterListResponse = export type ExperimentalWorkspaceAdaptorListResponse =
ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses]
export type ExperimentalWorkspaceListData = { export type ExperimentalWorkspaceListData = {
body?: never body?: never
+6 -6
View File
@@ -415,9 +415,9 @@
] ]
} }
}, },
"/experimental/workspace/adapter": { "/experimental/workspace/adaptor": {
"get": { "get": {
"operationId": "experimental.workspace.adapter.list", "operationId": "experimental.workspace.adaptor.list",
"parameters": [ "parameters": [
{ {
"in": "query", "in": "query",
@@ -434,11 +434,11 @@
} }
} }
], ],
"summary": "List workspace adapters", "summary": "List workspace adaptors",
"description": "List all available workspace adapters for the current project.", "description": "List all available workspace adaptors for the current project.",
"responses": { "responses": {
"200": { "200": {
"description": "Workspace adapters", "description": "Workspace adaptors",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -466,7 +466,7 @@
"x-codeSamples": [ "x-codeSamples": [
{ {
"lang": "js", "lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})"
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.14.31", "version": "1.14.30",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.14.31", "version": "1.14.30",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -110,7 +110,7 @@ On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). - `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. - `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend.
::: :::
--- ---
+1 -1
View File
@@ -1767,7 +1767,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon
### STACKIT ### STACKIT
STACKIT AI Model Serving provides fully managed sovereign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure.
1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project. 1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project.
+7 -7
View File
@@ -4,7 +4,7 @@ description: Type-safe JS client for opencode server.
--- ---
import config from "../../../config.mjs" import config from "../../../config.mjs"
export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/v2/gen/types.gen.ts`
The opencode JS/TS SDK provides a type-safe client for interacting with the server. The opencode JS/TS SDK provides a type-safe client for interacting with the server.
Use it to build integrations and control opencode programmatically. Use it to build integrations and control opencode programmatically.
@@ -28,7 +28,7 @@ npm install @opencode-ai/sdk
Create an instance of opencode: Create an instance of opencode:
```javascript ```javascript
import { createOpencode } from "@opencode-ai/sdk" import { createOpencode } from "@opencode-ai/sdk/v2"
const { client } = await createOpencode() const { client } = await createOpencode()
``` ```
@@ -52,7 +52,7 @@ This starts both a server and a client
You can pass a configuration object to customize behavior. The instance still picks up your `opencode.json`, but you can override or add configuration inline: You can pass a configuration object to customize behavior. The instance still picks up your `opencode.json`, but you can override or add configuration inline:
```javascript ```javascript
import { createOpencode } from "@opencode-ai/sdk" import { createOpencode } from "@opencode-ai/sdk/v2"
const opencode = await createOpencode({ const opencode = await createOpencode({
hostname: "127.0.0.1", hostname: "127.0.0.1",
@@ -72,7 +72,7 @@ opencode.server.close()
If you already have a running instance of opencode, you can create a client instance to connect to it: If you already have a running instance of opencode, you can create a client instance to connect to it:
```javascript ```javascript
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: "http://localhost:4096", baseUrl: "http://localhost:4096",
@@ -96,7 +96,7 @@ const client = createOpencodeClient({
The SDK includes TypeScript definitions for all API types. Import them directly: The SDK includes TypeScript definitions for all API types. Import them directly:
```typescript ```typescript
import type { Session, Message, Part } from "@opencode-ai/sdk" import type { Session, Message, Part } from "@opencode-ai/sdk/v2"
``` ```
All types are generated from the server's OpenAPI specification and available in the <a href={typesUrl}>types file</a>. All types are generated from the server's OpenAPI specification and available in the <a href={typesUrl}>types file</a>.
@@ -318,7 +318,7 @@ const { providers, default: defaults } = await client.config.providers()
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> | | `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> | | `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
| `session.unrevert({ path })` | Restore reverted messages | Returns <a href={typesUrl}><code>Session</code></a> | | `session.unrevert({ path })` | Restore reverted messages | Returns <a href={typesUrl}><code>Session</code></a> |
| `postSessionByIdPermissionsByPermissionId({ path, body })` | Respond to a permission request | Returns `boolean` | | `permission.reply({ path, body })` | Respond to a permission request | Returns `boolean` |
--- ---
@@ -360,7 +360,7 @@ await client.session.prompt({
| `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | | `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` |
| `find.files({ query })` | Find files and directories by name | `string[]` (paths) | | `find.files({ query })` | Find files and directories by name | `string[]` (paths) |
| `find.symbols({ query })` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> | | `find.symbols({ query })` | Find workspace symbols | <a href={typesUrl}><code>Symbol[]</code></a> |
| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` | | `file.read({ query })` | Read a file | `{ type: "text" \| "binary", content: string }` |
| `file.status({ query? })` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> | | `file.status({ query? })` | Get status for tracked files | <a href={typesUrl}><code>File[]</code></a> |
`find.files` supports a few optional query fields: `find.files` supports a few optional query fields:
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.14.31", "version": "1.14.30",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",