Compare commits

..

1 Commits

Author SHA1 Message Date
Dax Raad a2133466be fix: allow current project worktree directories by default 2026-04-17 15:24:02 -04:00
80 changed files with 1017 additions and 1514 deletions
+16 -16
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.11",
"version": "1.4.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -458,7 +458,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.11",
"version": "1.4.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
"x86_64-linux": "sha256-60KBy/mySuIKbBD5aCS4ZAQqnwZ4PjLMAqZH7gpKCFY=",
"aarch64-linux": "sha256-ROd9OfdCBQZntoAr32O3YVl7ljRgYvJma25U+jHwcts=",
"aarch64-darwin": "sha256-MjMfR73ZZLXtIXfuzqpjvD5RxmIRi9HA1jWXPvagU6w=",
"x86_64-darwin": "sha256-1BADHsSdMxJUbQ4DR/Ww4ZTt18H365lETJs7Fy7fsLc="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.11",
"version": "1.4.9",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.11",
"version": "1.4.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.11",
"version": "1.4.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.11",
"version": "1.4.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.11",
"version": "1.4.9",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.11"
version = "1.4.9"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.11",
"version": "1.4.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.11",
"version": "1.4.9",
"name": "opencode",
"type": "module",
"license": "MIT",
+19 -58
View File
@@ -189,46 +189,10 @@ Ordering for a route-group migration:
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
Temporary exception:
@@ -401,16 +365,17 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoint: `GET /config/providers`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
- `project` - `later`
best small reads: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `next`
- `workspace` - `later`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -428,12 +393,12 @@ Current instance route inventory:
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence:
Recommended near-term sequence after the first spike:
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` full read endpoint (`GET /config`)
3. `file` JSON read endpoints
4. `mcp` JSON read endpoints
1. `provider` auth read endpoint
2. `config` providers read endpoint
3. `project` read endpoints
4. `workspace` read endpoints
## Checklist
@@ -446,12 +411,8 @@ Recommended near-term sequence:
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [ ] port `workspace` read endpoints
- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
+6 -2
View File
@@ -79,10 +79,14 @@ export const layer = Layer.effect(
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (_ctx) {
Effect.fn("Agent.state")(function* (ctx) {
const cfg = yield* config.get()
const skillDirs = yield* skill.dirs()
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const whitelistedDirs = [
Truncate.GLOB,
path.join(Global.Path.data, "worktree", String(ctx.project.id), "*"),
...skillDirs.map((dir) => path.join(dir, "*")),
]
const defaults = Permission.fromConfig({
"*": "allow",
@@ -139,10 +139,15 @@ export function DialogSessionList() {
{desc}{" "}
<span
style={{
fg: workspaceStatus === "connected" ? theme.success : theme.error,
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
●
■
</span>
</>
)
@@ -139,13 +139,7 @@ export async function restoreWorkspaceSession(input: {
total: result.data.total,
})
input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -1,81 +0,0 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -9,7 +9,6 @@ import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
@@ -39,8 +38,6 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
export type PromptProps = {
@@ -95,7 +92,6 @@ export function Prompt(props: PromptProps) {
const args = useArgs()
const sdk = useSDK()
const route = useRoute()
const project = useProject()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
@@ -245,11 +241,9 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: async (dialog) => {
onSelect: (dialog) => {
if (!input.focused) return
const handled = await submit()
if (!handled) return
void submit()
dialog.clear()
},
},
@@ -634,48 +628,20 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
const agent = local.agent.current()
if (!agent) return false
if (!agent) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
return
}
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
return false
}
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
const workspaceID = workspaceSession?.workspaceID
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
return
}
let sessionID = props.sessionID
@@ -690,7 +656,7 @@ export function Prompt(props: PromptProps) {
variant: "error",
})
return true
return
}
sessionID = res.data.id
@@ -804,7 +770,6 @@ export function Prompt(props: PromptProps) {
})
}, 50)
input.clear()
return true
}
const exit = useExit()
@@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin: ConfigPlugin.Spec.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
@@ -158,12 +158,7 @@ export const layer = Layer.effect(
(dir) =>
npm
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(Effect.forkScoped),
{
@@ -1,4 +1,3 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
@@ -9,23 +8,10 @@ import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
@@ -62,12 +48,6 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
+3 -6
View File
@@ -15,7 +15,6 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -130,13 +129,11 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, {
env,
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
@@ -11,9 +11,6 @@ import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@/util/opencode-process"
ensureProcessMetadata("worker")
await Log.init({
print: process.argv.includes("--print-logs"),
+1 -1
View File
@@ -15,7 +15,7 @@ const log = Log.create({ service: "config" })
export const Info = z
.object({
model: ConfigModelID.zod.optional(),
model: ConfigModelID.optional(),
variant: z
.string()
.optional()
+10 -12
View File
@@ -1,12 +1,10 @@
export * as ConfigCommand from "./command"
import { Log } from "../util"
import { Schema } from "effect"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -14,15 +12,15 @@ import { ConfigModelID } from "./model-id"
const log = Log.create({ service: "config" })
export const Info = Schema.Struct({
template: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(ConfigModelID),
subtask: Schema.optional(Schema.Boolean),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.object({
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: ConfigModelID.optional(),
subtask: z.boolean().optional(),
})
export type Info = Schema.Schema.Type<typeof Info>
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
@@ -51,7 +49,7 @@ export async function load(dir: string) {
...md.data,
template: md.content.trim(),
}
const parsed = Info.zod.safeParse(config)
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
+11 -16
View File
@@ -97,10 +97,10 @@ export const Info = z
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), ConfigCommand.Info.zod)
.record(z.string(), ConfigCommand.Info)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
@@ -113,7 +113,7 @@ export const Info = z
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin: ConfigPlugin.Spec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -135,10 +135,10 @@ export const Info = z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.zod
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
.string()
.optional()
@@ -178,7 +178,7 @@ export const Info = z
.record(
z.string(),
z.union([
ConfigMCP.Info.zod,
ConfigMCP.Info,
z
.object({
enabled: z.boolean(),
@@ -188,8 +188,8 @@ export const Info = z
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.zod.optional(),
lsp: ConfigLSP.Info.zod.optional(),
formatter: ConfigFormatter.Info.optional(),
lsp: ConfigLSP.Info.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
@@ -518,12 +518,7 @@ export const layer = Layer.effect(
const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(
Effect.exit,
+10 -11
View File
@@ -1,16 +1,15 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import z from "zod"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: Schema.Number,
}) {
static readonly zod = zod(this)
}
export const ConsoleState = z.object({
consoleManagedProviders: z.array(z.string()),
activeOrgName: z.string().optional(),
switchableOrgCount: z.number().int().nonnegative(),
})
export const emptyConsoleState: ConsoleState = ConsoleState.make({
export type ConsoleState = z.infer<typeof ConsoleState>
export const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
})
}
+9 -13
View File
@@ -1,17 +1,13 @@
export * as ConfigFormatter from "./formatter"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import z from "zod"
export const Entry = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Entry = z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
})
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
export type Info = z.infer<typeof Info>
+30 -38
View File
@@ -1,45 +1,37 @@
export * as ConfigLSP from "./lsp"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import z from "zod"
import * as LSPServer from "../lsp/server"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Entry = Schema.Union([
Disabled,
Schema.Struct({
command: Schema.mutable(Schema.Array(Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
disabled: Schema.optional(Schema.Boolean),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
]).pipe(withStatics((s) => ({ zod: zod(s) })))
/**
* For custom (non-builtin) LSP server entries, `extensions` is required so the
* client knows which files the server should attach to. Builtin server IDs and
* explicitly disabled entries are exempt.
*/
export const requiresExtensionsForCustomServers = Schema.makeFilter<
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
>((data) => {
if (typeof data === "boolean") return undefined
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
const ok = Object.entries(data).every(([id, config]) => {
if ("disabled" in config && config.disabled) return true
if (serverIds.has(id)) return true
return "extensions" in config && Boolean(config.extensions)
})
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
export const Disabled = z.object({
disabled: z.literal(true),
})
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
.check(requiresExtensionsForCustomServers)
.pipe(withStatics((s) => ({ zod: zod(s) })))
export const Entry = z.union([
Disabled,
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
])
export type Info = Schema.Schema.Type<typeof Info>
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
(data) => {
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
)
export type Info = z.infer<typeof Info>
+62 -56
View File
@@ -1,62 +1,68 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import z from "zod"
export class Local extends Schema.Class<Local>("McpLocalConfig")({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Command and arguments to run the MCP server",
}),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Environment variables to set when running the MCP server",
}),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
}) {
static readonly zod = zod(this)
}
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
clientId: Schema.optional(Schema.String).annotate({
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
}),
clientSecret: Schema.optional(Schema.String).annotate({
description: "OAuth client secret (if required by the authorization server)",
}),
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
redirectUri: Schema.optional(Schema.String).annotate({
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
}),
}) {
static readonly zod = zod(this)
}
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Headers to send with the request",
}),
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}),
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
}) {
static readonly zod = zod(this)
}
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export const Info = Schema.Union([Local, Remote])
.annotate({ discriminator: "type" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
export * as ConfigMCP from "./mcp"
+1 -12
View File
@@ -1,14 +1,3 @@
import { Schema } from "effect"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
// The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a
// literal pointer to an outside schema — so the walker cannot re-derive it
// from AST metadata. Preserve the exact original Zod via ZodOverride.
export const ConfigModelID = Schema.String.annotate({
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
+30 -33
View File
@@ -1,8 +1,5 @@
export * as ConfigPermission from "./permission"
import { Schema } from "effect"
import z from "zod"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
@@ -11,20 +8,20 @@ const permissionPreprocess = (val: unknown) => {
return val
}
export const Action = Schema.Literals(["ask", "allow", "deny"])
.annotate({ identifier: "PermissionActionConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = Schema.Schema.Type<typeof Action>
export const Action = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type Action = z.infer<typeof Action>
export const Object = Schema.Record(Schema.String, Action)
.annotate({ identifier: "PermissionObjectConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Object = Schema.Schema.Type<typeof Object>
export const Object = z.record(z.string(), Action).meta({
ref: "PermissionObjectConfig",
})
export type Object = z.infer<typeof Object>
export const Rule = Schema.Union([Action, Object])
.annotate({ identifier: "PermissionRuleConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>
export const Rule = z.union([Action, Object]).meta({
ref: "PermissionRuleConfig",
})
export type Rule = z.infer<typeof Rule>
const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
@@ -44,25 +41,25 @@ export const Info = z
z
.object({
__originalKeys: z.string().array().optional(),
read: Rule.zod.optional(),
edit: Rule.zod.optional(),
glob: Rule.zod.optional(),
grep: Rule.zod.optional(),
list: Rule.zod.optional(),
bash: Rule.zod.optional(),
task: Rule.zod.optional(),
external_directory: Rule.zod.optional(),
todowrite: Action.zod.optional(),
question: Action.zod.optional(),
webfetch: Action.zod.optional(),
websearch: Action.zod.optional(),
codesearch: Action.zod.optional(),
lsp: Rule.zod.optional(),
doom_loop: Action.zod.optional(),
skill: Rule.zod.optional(),
read: Rule.optional(),
edit: Rule.optional(),
glob: Rule.optional(),
grep: Rule.optional(),
list: Rule.optional(),
bash: Rule.optional(),
task: Rule.optional(),
external_directory: Rule.optional(),
todowrite: Action.optional(),
question: Action.optional(),
webfetch: Action.optional(),
websearch: Action.optional(),
codesearch: Action.optional(),
lsp: Rule.optional(),
doom_loop: Action.optional(),
skill: Rule.optional(),
})
.catchall(Rule.zod)
.or(Action.zod),
.catchall(Rule)
.or(Action),
)
.transform(transform)
.meta({
+5 -9
View File
@@ -1,20 +1,16 @@
import { Glob } from "@opencode-ai/shared/util/glob"
import { Schema } from "effect"
import z from "zod"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import path from "path"
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Options = Schema.Schema.Type<typeof Options>
const Options = z.record(z.string(), z.unknown())
export type Options = z.infer<typeof Options>
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
// It answers "what should we load?" but says nothing about where that value came from.
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Spec = Schema.Schema.Type<typeof Spec>
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
export type Spec = z.infer<typeof Spec>
export type Scope = "global" | "local"
+9 -12
View File
@@ -1,16 +1,13 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import z from "zod"
export const Info = Schema.Struct({
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
description: "Additional paths to skill folders",
}),
urls: Schema.optional(Schema.Array(Schema.String)).annotate({
description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
}),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export type Info = Schema.Schema.Type<typeof Info>
export type Info = z.infer<typeof Info>
export * as ConfigSkills from "./skills"
+1 -1
View File
@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
name: string
description: string
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>, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}
@@ -115,8 +115,6 @@ export const create = fn(CreateInput, async (input) => {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}
await adaptor.create(config, env)
+20 -30
View File
@@ -4,11 +4,9 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { ensureProcessMetadata } from "@/util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@@ -21,34 +19,26 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
)
: undefined
function resource() {
const processMetadata = ensureProcessMetadata("main")
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
const resource = {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
},
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const logs = Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource,
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
@@ -68,7 +58,7 @@ const traces = async () => {
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource: resource(),
resource,
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
@@ -83,7 +73,7 @@ export const layer = !base
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs())
return Layer.mergeAll(trace, logs)
}),
)
+3 -2
View File
@@ -7,7 +7,6 @@ import { ripgrep } from "ripgrep"
import { Filesystem } from "@/util"
import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
const log = Log.create({ service: "ripgrep" })
@@ -158,7 +157,9 @@ type WorkerError = {
}
function env() {
const env = sanitizedProcessEnv()
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH
return env
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { ripgrep } from "ripgrep"
import { sanitizedProcessEnv } from "@/util/opencode-process"
function env() {
const env = sanitizedProcessEnv()
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH
return env
}
-5
View File
@@ -38,9 +38,6 @@ import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "./util/opencode-process"
const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -111,8 +108,6 @@ const cli = yargs(args)
Log.Default.info("opencode", {
version: InstallationVersion,
args: process.argv.slice(2),
process_role: processMetadata.processRole,
run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
+5 -11
View File
@@ -25,12 +25,7 @@ export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
add: {
name: string
version?: string
}[]
},
input?: { add: string[] },
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
@@ -142,18 +137,17 @@ export const layer = Layer.effect(
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
yield* reify({ add: input?.add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
@@ -169,7 +163,7 @@ export const layer = Layer.effect(
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []).map((pkg) => pkg.name),
...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {}
@@ -182,7 +176,7 @@ export const layer = Layer.effect(
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add })
yield* reify({ dir, add: input?.add })
return
}
}
@@ -1,5 +1,6 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
@@ -10,11 +10,6 @@ export const schema = z.object({
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
policy: z
.object({
state: z.string().optional(),
})
.optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
@@ -127,9 +122,7 @@ export async function get(
})
const result = { ...existing }
const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
)
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
-42
View File
@@ -12,41 +12,31 @@ import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = {
spec: string
options: ConfigPlugin.Options | undefined
deprecated: boolean
}
// A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
// A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
// Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void
// Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
// Called for operational failures such as install, compatibility, or dynamic import errors.
error?: (
candidate: Candidate,
retry: boolean,
@@ -56,16 +46,11 @@ export namespace PluginLoader {
) => void
}
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
//
// The stages here intentionally separate install/target resolution, entrypoint detection,
// and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve(
plan: Plan,
kind: PluginKind,
@@ -74,7 +59,6 @@ export namespace PluginLoader {
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
// First make sure the plugin exists locally, installing npm plugins on demand.
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
@@ -83,7 +67,6 @@ export namespace PluginLoader {
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
// Then inspect the target for the requested server/tui entrypoint.
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
@@ -103,8 +86,6 @@ export namespace PluginLoader {
},
}
// npm plugins can declare which opencode versions they support; file plugins are treated
// as local development code and skip this compatibility gate.
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@@ -115,7 +96,6 @@ export namespace PluginLoader {
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
// Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
@@ -127,8 +107,6 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } }
}
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
// import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
@@ -138,17 +116,11 @@ export namespace PluginLoader {
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
// Missing entrypoints are handled separately so callers can still inspect package metadata,
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
@@ -159,15 +131,11 @@ export namespace PluginLoader {
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
}
@@ -181,11 +149,6 @@ export namespace PluginLoader {
report?: Report
}
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins that initially failed are retried once after the
// caller finishes preparing dependencies. This supports local plugins that depend on an install
// step happening elsewhere before their entrypoint becomes loadable.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
@@ -197,9 +160,6 @@ export namespace PluginLoader {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
// Only local file plugins are retried. npm plugins already attempted installation during
// the first pass, while file plugins may need the caller's dependency preparation to finish.
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
@@ -207,8 +167,6 @@ export namespace PluginLoader {
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
+7 -7
View File
@@ -968,7 +968,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
family: model.family,
api: {
id: model.id,
url: model.provider?.api ?? provider.api ?? "",
url: model.provider?.api ?? provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
@@ -981,10 +981,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
output: model.limit.output,
},
capabilities: {
temperature: model.temperature ?? false,
reasoning: model.reasoning ?? false,
attachment: model.attachment ?? false,
toolcall: model.tool_call ?? true,
temperature: model.temperature,
reasoning: model.reasoning,
attachment: model.attachment,
toolcall: model.tool_call,
input: {
text: model.modalities?.input?.includes("text") ?? false,
audio: model.modalities?.input?.includes("audio") ?? false,
@@ -1001,7 +1001,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
},
interleaved: model.interleaved ?? false,
},
release_date: model.release_date ?? "",
release_date: model.release_date,
variants: {},
}
@@ -1143,7 +1143,7 @@ const layer: Layer.Layer<
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
},
status: model.status ?? existingModel?.status ?? "active",
name,
@@ -7,6 +7,7 @@ import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
import z from "zod"
import { errors } from "../../error"
import { WorkspaceRoutes } from "./workspace"
export function ControlPlaneRoutes(): Hono {
const app = new Hono()
@@ -157,4 +158,5 @@ export function ControlPlaneRoutes(): Hono {
return c.json(true)
},
)
.route("/experimental/workspace", WorkspaceRoutes())
}
@@ -5,6 +5,7 @@ import { Config } from "@/config"
import { Provider } from "@/provider"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest } from "./trace"
export const ConfigRoutes = lazy(() =>
@@ -51,13 +52,11 @@ export const ConfigRoutes = lazy(() =>
},
}),
validator("json", Config.Info),
async (c) =>
jsonRequest("ConfigRoutes.update", c, function* () {
const config = c.req.valid("json")
const cfg = yield* Config.Service
yield* cfg.update(config)
return config
}),
async (c) => {
const config = c.req.valid("json")
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
return c.json(config)
},
)
.get(
"/providers",
@@ -12,11 +12,11 @@ import { Config } from "@/config"
import { ConsoleState } from "@/config/console-state"
import { Account } from "@/account/account"
import { AccountID, OrgID } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect, Option } from "effect"
import { Agent } from "@/agent/agent"
import { jsonRequest, runRequest } from "./trace"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -49,24 +49,28 @@ export const ExperimentalRoutes = lazy(() =>
description: "Active Console provider metadata",
content: {
"application/json": {
schema: resolver(ConsoleState.zod),
schema: resolver(ConsoleState),
},
},
},
},
}),
async (c) =>
jsonRequest("ExperimentalRoutes.console.get", c, function* () {
const config = yield* Config.Service
const account = yield* Account.Service
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
concurrency: "unbounded",
})
return {
...state,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
}),
async (c) => {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const config = yield* Config.Service
const account = yield* Account.Service
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
concurrency: "unbounded",
})
return {
...state,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
}),
)
return c.json(result)
},
)
.get(
"/console/orgs",
@@ -85,25 +89,28 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
const account = yield* Account.Service
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
concurrency: "unbounded",
})
const info = Option.getOrUndefined(active)
const orgs = groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
)
return { orgs }
}),
async (c) => {
const orgs = await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
concurrency: "unbounded",
})
const info = Option.getOrUndefined(active)
return groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
)
}),
)
return c.json({ orgs })
},
)
.post(
"/console/switch",
@@ -123,13 +130,16 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", ConsoleSwitchBody),
async (c) =>
jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
const body = c.req.valid("json")
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
return true
}),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
}),
)
return c.json(true)
},
)
.get(
"/tool/ids",
@@ -150,11 +160,15 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
async (c) =>
jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
async (c) => {
const ids = await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
return c.json(ids)
},
)
.get(
"/tool",
@@ -196,9 +210,7 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await runRequest(
"ExperimentalRoutes.tool.list",
c,
const tools = await AppRuntime.runPromise(
Effect.gen(function* () {
const agents = yield* Agent.Service
const registry = yield* ToolRegistry.Service
@@ -237,12 +249,11 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.CreateInput.optional()),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
const body = c.req.valid("json")
const svc = yield* Worktree.Service
return yield* svc.create(body)
}),
async (c) => {
const body = c.req.valid("json")
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
return c.json(worktree)
},
)
.get(
"/worktree",
@@ -261,11 +272,10 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
const svc = yield* Project.Service
return yield* svc.sandboxes(Instance.project.id)
}),
async (c) => {
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
return c.json(sandboxes)
},
)
.delete(
"/worktree",
@@ -286,15 +296,14 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.RemoveInput),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
const body = c.req.valid("json")
const worktree = yield* Worktree.Service
const project = yield* Project.Service
yield* worktree.remove(body)
yield* project.removeSandbox(Instance.project.id, body.directory)
return true
}),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
)
return c.json(true)
},
)
.post(
"/worktree/reset",
@@ -315,13 +324,11 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.ResetInput),
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
const body = c.req.valid("json")
const svc = yield* Worktree.Service
yield* svc.reset(body)
return true
}),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
return c.json(true)
},
)
.get(
"/session",
@@ -399,10 +406,15 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
},
),
)
@@ -1,12 +1,13 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { LSP } from "@/lsp"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const FileRoutes = lazy(() =>
new Hono()
@@ -33,13 +34,13 @@ export const FileRoutes = lazy(() =>
pattern: z.string(),
}),
),
async (c) =>
jsonRequest("FileRoutes.findText", c, function* () {
const pattern = c.req.valid("query").pattern
const svc = yield* Ripgrep.Service
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
return result.items
}),
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
)
return c.json(result.items)
},
)
.get(
"/find/file",
@@ -67,17 +68,25 @@ export const FileRoutes = lazy(() =>
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
async (c) =>
jsonRequest("FileRoutes.findFile", c, function* () {
const query = c.req.valid("query")
const svc = yield* File.Service
return yield* svc.search({
query: query.query,
limit: query.limit ?? 10,
dirs: query.dirs !== "false",
type: query.type,
})
}),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type
const limit = c.req.valid("query").limit
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) =>
svc.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
}),
)
}),
)
return c.json(results)
},
)
.get(
"/find/symbol",
@@ -129,11 +138,15 @@ export const FileRoutes = lazy(() =>
path: z.string(),
}),
),
async (c) =>
jsonRequest("FileRoutes.list", c, function* () {
const svc = yield* File.Service
return yield* svc.list(c.req.valid("query").path)
}),
async (c) => {
const path = c.req.valid("query").path
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(path))
}),
)
return c.json(content)
},
)
.get(
"/file/content",
@@ -158,11 +171,15 @@ export const FileRoutes = lazy(() =>
path: z.string(),
}),
),
async (c) =>
jsonRequest("FileRoutes.read", c, function* () {
const svc = yield* File.Service
return yield* svc.read(c.req.valid("query").path)
}),
async (c) => {
const path = c.req.valid("query").path
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(path))
}),
)
return c.json(content)
},
)
.get(
"/file/status",
@@ -181,10 +198,13 @@ export const FileRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("FileRoutes.status", c, function* () {
const svc = yield* File.Service
return yield* svc.status()
}),
async (c) => {
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
return c.json(content)
},
),
)
@@ -26,8 +26,7 @@ import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
import { InstanceMiddleware } from "./middleware"
import { jsonRequest } from "./trace"
import { AppRuntime } from "@/effect/app-runtime"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono()
@@ -141,14 +140,19 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.vcs.get", c, function* () {
const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
})
return { branch, default_branch }
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
})
return { branch, default_branch }
}),
),
)
},
)
.get(
"/vcs/diff",
@@ -173,11 +177,16 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
mode: Vcs.Mode,
}),
),
async (c) =>
jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
},
)
.get(
"/command",
@@ -196,11 +205,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.command.list", c, function* () {
const svc = yield* Command.Service
return yield* svc.list()
}),
async (c) => {
const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
return c.json(commands)
},
)
.get(
"/agent",
@@ -219,11 +227,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.agent.list", c, function* () {
const svc = yield* Agent.Service
return yield* svc.list()
}),
async (c) => {
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
return c.json(modes)
},
)
.get(
"/skill",
@@ -242,11 +249,15 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.skill.list", c, function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
async (c) => {
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
return c.json(skills)
},
)
.get(
"/lsp",
@@ -265,11 +276,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.lsp.status", c, function* () {
const lsp = yield* LSP.Service
return yield* lsp.status()
}),
async (c) => {
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
return c.json(items)
},
)
.get(
"/formatter",
@@ -288,10 +298,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.formatter.status", c, function* () {
const svc = yield* Format.Service
return yield* svc.status()
}),
async (c) => {
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
},
)
}
@@ -2,11 +2,12 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "@/mcp"
import { Config } from "@/config"
import { ConfigMCP } from "@/config/mcp"
import { AppRuntime } from "@/effect/app-runtime"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect } from "effect"
import { jsonRequest, runRequest } from "./trace"
export const McpRoutes = lazy(() =>
new Hono()
@@ -27,11 +28,9 @@ export const McpRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("McpRoutes.status", c, function* () {
const mcp = yield* MCP.Service
return yield* mcp.status()
}),
async (c) => {
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
},
)
.post(
"/",
@@ -55,16 +54,14 @@ export const McpRoutes = lazy(() =>
"json",
z.object({
name: z.string(),
config: ConfigMCP.Info.zod,
config: ConfigMCP.Info,
}),
),
async (c) =>
jsonRequest("McpRoutes.add", c, function* () {
const { name, config } = c.req.valid("json")
const mcp = yield* MCP.Service
const result = yield* mcp.add(name, config)
return result.status
}),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
return c.json(result.status)
},
)
.post(
"/:name/auth",
@@ -90,9 +87,7 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const result = await runRequest(
"McpRoutes.auth.start",
c,
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
@@ -134,13 +129,12 @@ export const McpRoutes = lazy(() =>
code: z.string().describe("Authorization code from OAuth callback"),
}),
),
async (c) =>
jsonRequest("McpRoutes.auth.callback", c, function* () {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const mcp = yield* MCP.Service
return yield* mcp.finishAuth(name, code)
}),
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
return c.json(status)
},
)
.post(
"/:name/auth/authenticate",
@@ -162,9 +156,7 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const result = await runRequest(
"McpRoutes.auth.authenticate",
c,
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
@@ -199,13 +191,11 @@ export const McpRoutes = lazy(() =>
...errors(404),
},
}),
async (c) =>
jsonRequest("McpRoutes.auth.remove", c, function* () {
const name = c.req.param("name")
const mcp = yield* MCP.Service
yield* mcp.removeAuth(name)
return { success: true as const }
}),
async (c) => {
const name = c.req.param("name")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
return c.json({ success: true as const })
},
)
.post(
"/:name/connect",
@@ -224,13 +214,11 @@ export const McpRoutes = lazy(() =>
},
}),
validator("param", z.object({ name: z.string() })),
async (c) =>
jsonRequest("McpRoutes.connect", c, function* () {
const { name } = c.req.valid("param")
const mcp = yield* MCP.Service
yield* mcp.connect(name)
return true
}),
async (c) => {
const { name } = c.req.valid("param")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
return c.json(true)
},
)
.post(
"/:name/disconnect",
@@ -249,12 +237,10 @@ export const McpRoutes = lazy(() =>
},
}),
validator("param", z.object({ name: z.string() })),
async (c) =>
jsonRequest("McpRoutes.disconnect", c, function* () {
const { name } = c.req.valid("param")
const mcp = yield* MCP.Service
yield* mcp.disconnect(name)
return true
}),
async (c) => {
const { name } = c.req.valid("param")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
return c.json(true)
},
),
)
@@ -1,35 +0,0 @@
import type { MiddlewareHandler } from "hono"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceID } from "@/control-plane/schema"
export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = AppFileSystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return WorkspaceContext.provide({
workspaceID,
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
},
})
}
}
@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const PermissionRoutes = lazy(() =>
new Hono()
@@ -34,18 +34,20 @@ export const PermissionRoutes = lazy(() =>
}),
),
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
async (c) =>
jsonRequest("PermissionRoutes.reply", c, function* () {
const params = c.req.valid("param")
const json = c.req.valid("json")
const svc = yield* Permission.Service
yield* svc.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
})
return true
}),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
}),
),
)
return c.json(true)
},
)
.get(
"/",
@@ -64,10 +66,9 @@ export const PermissionRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("PermissionRoutes.list", c, function* () {
const svc = yield* Permission.Service
return yield* svc.list()
}),
async (c) => {
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
return c.json(permissions)
},
),
)
@@ -9,7 +9,6 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest, runRequest } from "./trace"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -76,9 +75,7 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const dir = Instance.directory
const prev = Instance.project
const next = await runRequest(
"ProjectRoutes.initGit",
c,
const next = await AppRuntime.runPromise(
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
)
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
@@ -111,12 +108,11 @@ export const ProjectRoutes = lazy(() =>
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) =>
jsonRequest("ProjectRoutes.update", c, function* () {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const svc = yield* Project.Service
return yield* svc.update({ ...body, projectID })
}),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
return c.json(project)
},
),
)
@@ -6,11 +6,11 @@ import { Provider } from "@/provider"
import { ModelsDev } from "@/provider"
import { ProviderAuth } from "@/provider"
import { ProviderID } from "@/provider/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { mapValues } from "remeda"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect } from "effect"
import { jsonRequest } from "./trace"
export const ProviderRoutes = lazy(() =>
new Hono()
@@ -31,31 +31,39 @@ export const ProviderRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("ProviderRoutes.list", c, function* () {
const svc = yield* Provider.Service
const cfg = yield* Config.Service
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
async (c) => {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const cfg = yield* Config.Service
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
}
const connected = yield* svc.list()
const providers = Object.assign(
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
}),
const connected = yield* svc.list()
const providers = Object.assign(
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
}),
)
return c.json({
all: result.all,
default: result.default,
connected: result.connected,
})
},
)
.get(
"/auth",
@@ -74,11 +82,9 @@ export const ProviderRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("ProviderRoutes.auth", c, function* () {
const svc = yield* ProviderAuth.Service
return yield* svc.methods()
}),
async (c) => {
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
},
)
.post(
"/:providerID/oauth/authorize",
@@ -105,17 +111,20 @@ export const ProviderRoutes = lazy(() =>
}),
),
validator("json", ProviderAuth.AuthorizeInput.zod),
async (c) =>
jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const svc = yield* ProviderAuth.Service
return yield* svc.authorize({
providerID,
method,
inputs,
})
}),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const result = await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.authorize({
providerID,
method,
inputs,
}),
),
)
return c.json(result)
},
)
.post(
"/:providerID/oauth/callback",
@@ -142,17 +151,19 @@ export const ProviderRoutes = lazy(() =>
}),
),
validator("json", ProviderAuth.CallbackInput.zod),
async (c) =>
jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
const svc = yield* ProviderAuth.Service
yield* svc.callback({
providerID,
method,
code,
})
return true
}),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.callback({
providerID,
method,
code,
}),
),
)
return c.json(true)
},
),
)
@@ -8,7 +8,6 @@ import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "@/storage"
import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
@@ -29,11 +28,16 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
},
}),
async (c) =>
jsonRequest("PtyRoutes.list", c, function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
),
)
},
)
.post(
"/",
@@ -54,11 +58,15 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("json", Pty.CreateInput),
async (c) =>
jsonRequest("PtyRoutes.create", c, function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
)
return c.json(info)
},
)
.get(
"/:ptyID",
@@ -80,9 +88,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
const info = await runRequest(
"PtyRoutes.get",
c,
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
@@ -114,11 +120,15 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
async (c) =>
jsonRequest("PtyRoutes.update", c, function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
}),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
}),
)
return c.json(info)
},
)
.delete(
"/:ptyID",
@@ -139,12 +149,15 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) =>
jsonRequest("PtyRoutes.remove", c, function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
return true
}),
async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
}),
)
return c.json(true)
},
)
.get(
"/:ptyID/connect",
@@ -181,9 +194,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
})()
let handler: Handler | undefined
if (
!(await runRequest(
"PtyRoutes.connect",
c,
!(await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
@@ -221,7 +232,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
}).pipe(Effect.withSpan("PtyRoutes.connect.open")),
}),
)
ready = true
for (const msg of pending) handler?.onMessage(msg)
@@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "@/question"
import { AppRuntime } from "@/effect/app-runtime"
import z from "zod"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
const Reply = z.object({
answers: Question.Answer.zod
@@ -33,11 +33,10 @@ export const QuestionRoutes = lazy(() =>
},
},
}),
async (c) =>
jsonRequest("QuestionRoutes.list", c, function* () {
const svc = yield* Question.Service
return yield* svc.list()
}),
async (c) => {
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
return c.json(questions)
},
)
.post(
"/:requestID/reply",
@@ -64,17 +63,19 @@ export const QuestionRoutes = lazy(() =>
}),
),
validator("json", Reply),
async (c) =>
jsonRequest("QuestionRoutes.reply", c, function* () {
const params = c.req.valid("param")
const json = c.req.valid("json")
const svc = yield* Question.Service
yield* svc.reply({
requestID: params.requestID,
answers: json.answers,
})
return true
}),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await AppRuntime.runPromise(
Question.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
answers: json.answers,
}),
),
)
return c.json(true)
},
)
.post(
"/:requestID/reject",
@@ -100,12 +101,10 @@ export const QuestionRoutes = lazy(() =>
requestID: QuestionID.zod,
}),
),
async (c) =>
jsonRequest("QuestionRoutes.reject", c, function* () {
const params = c.req.valid("param")
const svc = yield* Question.Service
yield* svc.reject(params.requestID)
return true
}),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
return c.json(true)
},
),
)
@@ -14,6 +14,7 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot"
import { Command } from "@/command"
@@ -25,7 +26,7 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/shared/util/error"
import { jsonRequest, runRequest } from "./trace"
import { jsonRequest } from "./trace"
const log = Log.create({ service: "server" })
@@ -217,12 +218,11 @@ export const SessionRoutes = lazy(() =>
},
}),
validator("json", Session.CreateInput),
async (c) =>
jsonRequest("SessionRoutes.create", c, function* () {
const body = c.req.valid("json") ?? {}
const svc = yield* SessionShare.Service
return yield* svc.create(body)
}),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
return c.json(session)
},
)
.delete(
"/:sessionID",
@@ -248,13 +248,11 @@ export const SessionRoutes = lazy(() =>
sessionID: Session.RemoveInput,
}),
),
async (c) =>
jsonRequest("SessionRoutes.delete", c, function* () {
const sessionID = c.req.valid("param").sessionID
const svc = yield* Session.Service
yield* svc.remove(sessionID)
return true
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
return c.json(true)
},
)
.patch(
"/:sessionID",
@@ -292,28 +290,32 @@ export const SessionRoutes = lazy(() =>
.optional(),
}),
),
async (c) =>
jsonRequest("SessionRoutes.update", c, function* () {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const session = yield* Session.Service
const current = yield* session.get(sessionID)
async (c) => {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const current = yield* session.get(sessionID)
if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
yield* session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived })
}
if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
yield* session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived })
}
return yield* session.get(sessionID)
}),
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
.post(
@@ -349,20 +351,22 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.init", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* SessionPrompt.Service
yield* svc.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
})
return true
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
}),
),
)
return c.json(true)
},
)
.post(
"/:sessionID/fork",
@@ -388,13 +392,12 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", Session.ForkInput.omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.fork", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* Session.Service
return yield* svc.fork({ ...body, sessionID })
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
return c.json(result)
},
)
.post(
"/:sessionID/abort",
@@ -420,12 +423,10 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.abort", c, function* () {
const svc = yield* SessionPrompt.Service
yield* svc.cancel(c.req.valid("param").sessionID)
return true
}),
async (c) => {
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
return c.json(true)
},
)
.post(
"/:sessionID/share",
@@ -451,14 +452,18 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.share", c, function* () {
const sessionID = c.req.valid("param").sessionID
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
.get(
"/:sessionID/diff",
@@ -489,16 +494,19 @@ export const SessionRoutes = lazy(() =>
messageID: SessionSummary.DiffInput.shape.messageID,
}),
),
async (c) =>
jsonRequest("SessionRoutes.diff", c, function* () {
const query = c.req.valid("query")
const params = c.req.valid("param")
const summary = yield* SessionSummary.Service
return yield* summary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
})
}),
async (c) => {
const query = c.req.valid("query")
const params = c.req.valid("param")
const result = await AppRuntime.runPromise(
SessionSummary.Service.use((summary) =>
summary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
}),
),
)
return c.json(result)
},
)
.delete(
"/:sessionID/share",
@@ -524,14 +532,18 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.unshare", c, function* () {
const sessionID = c.req.valid("param").sessionID
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
)
.post(
"/:sessionID/summarize",
@@ -565,40 +577,43 @@ export const SessionRoutes = lazy(() =>
auto: z.boolean().optional().default(false),
}),
),
async (c) =>
jsonRequest("SessionRoutes.summarize", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || defaultAgent
break
yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || defaultAgent
break
}
}
}
yield* compact.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
yield* prompt.loop({ sessionID })
return true
}),
yield* compact.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
yield* prompt.loop({ sessionID })
}),
)
return c.json(true)
},
)
.get(
"/:sessionID/message",
@@ -660,9 +675,7 @@ export const SessionRoutes = lazy(() =>
const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined || query.limit === 0) {
const messages = await runRequest(
"SessionRoutes.messages",
c,
const messages = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
yield* session.get(sessionID)
@@ -753,18 +766,21 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.deleteMessage", c, function* () {
const params = c.req.valid("param")
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
return true
}),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
}),
)
return c.json(true)
},
)
.delete(
"/:sessionID/message/:messageID/part/:partID",
@@ -791,17 +807,19 @@ export const SessionRoutes = lazy(() =>
partID: PartID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.deletePart", c, function* () {
const params = c.req.valid("param")
const svc = yield* Session.Service
yield* svc.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
return true
}),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
}),
),
)
return c.json(true)
},
)
.patch(
"/:sessionID/message/:messageID/part/:partID",
@@ -837,10 +855,8 @@ export const SessionRoutes = lazy(() =>
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
return jsonRequest("SessionRoutes.updatePart", c, function* () {
const svc = yield* Session.Service
return yield* svc.updatePart(body)
})
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
return c.json(part)
},
)
.post(
@@ -879,9 +895,7 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await runRequest(
"SessionRoutes.prompt",
c,
const msg = await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
)
void stream.write(JSON.stringify(msg))
@@ -912,17 +926,15 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
void runRequest(
"SessionRoutes.prompt_async",
c,
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
void Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch(
(err) => {
log.error("prompt_async failed", { sessionID, error: err })
void Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
},
)
return c.body(null, 204)
},
@@ -957,13 +969,12 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.command", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* SessionPrompt.Service
return yield* svc.command({ ...body, sessionID })
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
return c.json(msg)
},
)
.post(
"/:sessionID/shell",
@@ -990,13 +1001,12 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
async (c) =>
jsonRequest("SessionRoutes.shell", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* SessionPrompt.Service
return yield* svc.shell({ ...body, sessionID })
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
return c.json(msg)
},
)
.post(
"/:sessionID/revert",
@@ -1026,13 +1036,15 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
return jsonRequest("SessionRoutes.revert", c, function* () {
const svc = yield* SessionRevert.Service
return yield* svc.revert({
sessionID,
...c.req.valid("json"),
})
})
const session = await AppRuntime.runPromise(
SessionRevert.Service.use((svc) =>
svc.revert({
sessionID,
...c.req.valid("json"),
}),
),
)
return c.json(session)
},
)
.post(
@@ -1059,12 +1071,11 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) =>
jsonRequest("SessionRoutes.unrevert", c, function* () {
const sessionID = c.req.valid("param").sessionID
const svc = yield* SessionRevert.Service
return yield* svc.unrevert({ sessionID })
}),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
return c.json(session)
},
)
.post(
"/:sessionID/permissions/:permissionID",
@@ -1093,15 +1104,17 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", z.object({ response: Permission.Reply.zod })),
async (c) =>
jsonRequest("SessionRoutes.permissionRespond", c, function* () {
const params = c.req.valid("param")
const svc = yield* Permission.Service
yield* svc.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
return true
}),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
}),
),
)
return c.json(true)
},
),
)
@@ -4,10 +4,10 @@ import z from "zod"
import { Bus } from "@/bus"
import { Session } from "@/session"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
const TuiRequest = z.object({
path: z.string(),
@@ -371,11 +371,7 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await runRequest(
"TuiRoutes.sessionSelect",
c,
Session.Service.use((svc) => svc.get(sessionID)),
)
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
+36 -9
View File
@@ -1,10 +1,16 @@
import { generateSpecs } from "hono-openapi"
import { Hono } from "hono"
import type { MiddlewareHandler } from "hono"
import { adapter } from "#hono"
import { lazy } from "@/util/lazy"
import { Log } from "@/util"
import { Flag } from "@/flag/flag"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { MDNS } from "./mdns"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence"
@@ -14,8 +20,6 @@ import { ControlPlaneRoutes } from "./routes/control"
import { UIRoutes } from "./routes/ui"
import { GlobalRoutes } from "./routes/global"
import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -44,6 +48,34 @@ function create(opts: { cors?: string[] }) {
const runtime = adapter.create(app)
function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = AppFileSystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return WorkspaceContext.provide({
workspaceID,
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
},
})
}
}
if (Flag.OPENCODE_WORKSPACE_ID) {
return {
app: app
@@ -56,14 +88,9 @@ function create(opts: { cors?: string[] }) {
return {
app: app
.use(InstanceMiddleware())
.route("/", ControlPlaneRoutes())
.route(
"/",
new Hono()
.use(InstanceMiddleware())
.route("/experimental/workspace", WorkspaceRoutes())
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)),
)
.use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket))
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", UIRoutes()),
runtime,
+1 -4
View File
@@ -10,7 +10,6 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { Log } from "@/util"
import { ServerProxy } from "./proxy"
@@ -43,9 +42,7 @@ async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
const session = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
).catch(() => undefined)
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
return session?.workspaceID
}
+1 -1
View File
@@ -213,7 +213,7 @@ export const layer: Layer.Layer<
return true
})
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
switch (value.type) {
case "start":
yield* status.set(ctx.sessionID, { type: "busy" })
+1 -1
View File
@@ -649,7 +649,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
return input.partID
})
const updatePartDelta = Effect.fnUntraced(function* (input: {
const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: {
sessionID: SessionID
messageID: MessageID
partID: PartID
+8 -33
View File
@@ -38,9 +38,8 @@ const ShareSchema = Schema.Struct({
export type Share = typeof ShareSchema.Type
type State = {
queue: Map<SessionID, Map<string, Data>>
queue: Map<string, { data: Map<string, Data> }>
scope: Scope.Closeable
shared: Map<SessionID, Share | null>
}
type Data =
@@ -119,20 +118,17 @@ export const layer = Layer.effect(
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
return Effect.gen(function* () {
if (disabled) return
const share = yield* getCached(sessionID)
if (!share) return
const s = yield* InstanceState.get(state)
const existing = s.queue.get(sessionID)
if (existing) {
for (const item of data) {
existing.set(key(item), item)
existing.data.set(key(item), item)
}
return
}
const next = new Map(data.map((item) => [key(item), item]))
s.queue.set(sessionID, next)
s.queue.set(sessionID, { data: next })
yield* flush(sessionID).pipe(
Effect.delay(1000),
Effect.catchCause((cause) =>
@@ -147,14 +143,13 @@ export const layer = Layer.effect(
const state: InstanceState.InstanceState<State> = yield* InstanceState.make<State>(
Effect.fn("ShareNext.state")(function* (_ctx) {
const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() }
const cache: State = { queue: new Map(), scope: yield* Scope.make() }
yield* Effect.addFinalizer(() =>
Scope.close(cache.scope, Exit.void).pipe(
Effect.andThen(
Effect.sync(() => {
cache.queue.clear()
cache.shared.clear()
}),
),
),
@@ -232,18 +227,6 @@ export const layer = Layer.effect(
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
})
const getCached = Effect.fnUntraced(function* (sessionID: SessionID) {
const s = yield* InstanceState.get(state)
if (s.shared.has(sessionID)) {
const cached = s.shared.get(sessionID)
return cached === null ? undefined : cached
}
const share = yield* get(sessionID)
s.shared.set(sessionID, share ?? null)
return share
})
const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) {
if (disabled) return
const s = yield* InstanceState.get(state)
@@ -252,13 +235,13 @@ export const layer = Layer.effect(
s.queue.delete(sessionID)
const share = yield* getCached(sessionID)
const share = yield* get(sessionID)
if (!share) return
const req = yield* request()
const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe(
HttpClientRequest.setHeaders(req.headers),
HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }),
HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }),
Effect.flatMap((r) => http.execute(r)),
)
@@ -324,7 +307,6 @@ export const layer = Layer.effect(
.run(),
)
const s = yield* InstanceState.get(state)
s.shared.set(sessionID, result)
yield* full(sessionID).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
@@ -339,13 +321,8 @@ export const layer = Layer.effect(
const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
if (disabled) return
log.info("removing share", { sessionID })
const s = yield* InstanceState.get(state)
const share = yield* getCached(sessionID)
if (!share) {
s.shared.delete(sessionID)
s.queue.delete(sessionID)
return
}
const share = yield* get(sessionID)
if (!share) return
const req = yield* request()
yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe(
@@ -355,8 +332,6 @@ export const layer = Layer.effect(
)
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
s.shared.delete(sessionID)
s.queue.delete(sessionID)
})
return Service.of({ init, url, request, create, remove })
+4 -32
View File
@@ -16,34 +16,13 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny {
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
if (override) return override
let out = body(ast)
for (const check of ast.checks ?? []) {
out = applyCheck(out, check, ast)
}
const out = body(ast)
const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast)
const next = desc ? out.describe(desc) : out
return ref ? next.meta({ ref }) : next
}
function applyCheck(out: z.ZodTypeAny, check: SchemaAST.Check<any>, ast: SchemaAST.AST): z.ZodTypeAny {
if (check._tag === "FilterGroup") {
return check.checks.reduce((acc, sub) => applyCheck(acc, sub, ast), out)
}
return out.superRefine((value, ctx) => {
const issue = check.run(value, ast, {} as any)
if (!issue) return
const message = issueMessage(issue) ?? (check.annotations as any)?.message ?? "Validation failed"
ctx.addIssue({ code: "custom", message })
})
}
function issueMessage(issue: any): string | undefined {
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
if (typeof issue?.message === "string") return issue.message
return undefined
}
function body(ast: SchemaAST.AST): z.ZodTypeAny {
if (SchemaAST.isOptional(ast)) return opt(ast)
@@ -119,16 +98,9 @@ function object(ast: SchemaAST.Objects): z.ZodTypeAny {
}
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
// Pure variadic arrays: { elements: [], rest: [item] }
if (ast.elements.length === 0) {
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
// Tuples with a variadic tail (...rest) are not yet supported.
if (ast.rest.length > 0) return fail(ast)
const items = ast.elements.map(walk)
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
if (ast.elements.length > 0) return fail(ast)
if (ast.rest.length !== 1) return fail(ast)
return z.array(walk(ast.rest[0]))
}
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
@@ -1,24 +0,0 @@
export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
export function ensureRunID() {
return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
}
export function ensureProcessRole(fallback: "main" | "worker") {
return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
}
export function ensureProcessMetadata(fallback: "main" | "worker") {
return {
runID: ensureRunID(),
processRole: ensureProcessRole(fallback),
}
}
export function sanitizedProcessEnv(overrides?: Record<string, string>) {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
return overrides ? Object.assign(env, overrides) : env
}
@@ -4,6 +4,7 @@ import path from "path"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { Global } from "../../src/global"
import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
@@ -597,6 +598,21 @@ description: Permission skill.
}
})
test("managed worktree directories are allowed for external_directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await load(tmp.path, (svc) => svc.get("build"))
const target = path.join(Global.Path.data, "worktree", String(Instance.project.id), "sandbox", "notes.md")
const other = path.join(Global.Path.data, "worktree", "proj_other", "sandbox", "notes.md")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", other, build!.permission).action).toBe("ask")
},
})
})
test("defaultAgent returns build when no default_agent config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
-87
View File
@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ConfigLSP } from "../../src/config/lsp"
// The LSP config refinement enforces: any custom (non-builtin) LSP server
// entry must declare an `extensions` array so the client knows which files
// the server should attach to. Builtin server IDs and explicitly disabled
// entries are exempt.
//
// Both validation paths must honor this rule:
// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer)
// - `ConfigLSP.Info.zod.parse(...)` (derived Zod)
//
// `typescript` is a builtin server id (see src/lsp/server.ts).
describe("ConfigLSP.Info refinement", () => {
const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info)
describe("accepted inputs", () => {
test("true and false pass (top-level toggle)", () => {
expect(decodeEffect(true)).toBe(true)
expect(decodeEffect(false)).toBe(false)
expect(ConfigLSP.Info.zod.parse(true)).toBe(true)
expect(ConfigLSP.Info.zod.parse(false)).toBe(false)
})
test("builtin server with no extensions passes", () => {
const input = { typescript: { command: ["typescript-language-server", "--stdio"] } }
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("custom server WITH extensions passes", () => {
const input = {
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
}
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("disabled custom server passes (no extensions needed)", () => {
const input = { "my-lsp": { disabled: true as const } }
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("mix of builtin and custom with extensions passes", () => {
const input = {
typescript: { command: ["typescript-language-server", "--stdio"] },
"my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] },
}
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
})
describe("rejected inputs", () => {
const expectedMessage = "For custom LSP servers, 'extensions' array is required."
test("custom server WITHOUT extensions fails via Effect decode", () => {
expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage)
})
test("custom server WITHOUT extensions fails via derived Zod", () => {
const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } })
expect(result.success).toBe(false)
expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true)
})
test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => {
// Boolean(['']) is true, so a non-empty array of strings is fine.
// Boolean([]) is also true in JS, so empty arrays are accepted by the
// refinement. This test documents current behavior.
const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } }
expect(decodeEffect(input)).toEqual(input)
expect(ConfigLSP.Info.zod.parse(input)).toEqual(input)
})
test("custom server without extensions mixed with a valid builtin still fails", () => {
const input = {
typescript: { command: ["typescript-language-server", "--stdio"] },
"my-lsp": { command: ["my-lsp-bin"] },
}
expect(() => decodeEffect(input)).toThrow(expectedMessage)
expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false)
})
})
})
@@ -1916,7 +1916,7 @@ test("mode cost preserves over-200k pricing from base model", () => {
},
},
},
} as unknown as ModelsDev.Provider
} as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"]
expect(model.cost.input).toEqual(5)
@@ -1934,38 +1934,6 @@ test("mode cost preserves over-200k pricing from base model", () => {
})
})
test("models.dev normalization fills required response fields", () => {
const provider = {
id: "gateway",
name: "Gateway",
env: [],
models: {
"gpt-5.4": {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
cost: {
input: 2.5,
output: 15,
},
limit: {
context: 1_050_000,
input: 922_000,
output: 128_000,
},
},
},
} as unknown as ModelsDev.Provider
const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4"]
expect(model.api.url).toBe("")
expect(model.capabilities.temperature).toBe(false)
expect(model.capabilities.reasoning).toBe(false)
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.toolcall).toBe(true)
expect(model.release_date).toBe("")
})
test("model variants are generated for reasoning models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
+2 -79
View File
@@ -61,32 +61,8 @@ describe("util.effect-zod", () => {
})
})
describe("Tuples", () => {
test("fixed-length tuple parses matching array", () => {
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
expect(out.parse(["a", 1])).toEqual(["a", 1])
expect(out.safeParse(["a"]).success).toBe(false)
expect(out.safeParse(["a", "b"]).success).toBe(false)
})
test("single-element tuple parses a one-element array", () => {
const out = zod(Schema.Tuple([Schema.Boolean]))
expect(out.parse([true])).toEqual([true])
expect(out.safeParse([true, false]).success).toBe(false)
})
test("tuple inside a union picks the right branch", () => {
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
expect(out.parse("hello")).toBe("hello")
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
expect(out.safeParse(["foo"]).success).toBe(false)
})
test("plain arrays still work (no element positions)", () => {
const out = zod(Schema.Array(Schema.String))
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
expect(out.parse([])).toEqual([])
})
test("throws for unsupported tuple schemas", () => {
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema")
})
test("string literal unions produce z.enum with enum in JSON Schema", () => {
@@ -210,57 +186,4 @@ describe("util.effect-zod", () => {
const schema = json(zod(Parent)) as any
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
})
describe("Schema.check translation", () => {
test("filter returning string triggers refinement with that message", () => {
const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
const schema = zod(Schema.Number.check(isEven))
expect(schema.parse(4)).toBe(4)
const result = schema.safeParse(3)
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toBe("expected an even number")
})
test("filter returning false triggers refinement with fallback message", () => {
const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
const schema = zod(Schema.String.check(nonEmpty))
expect(schema.parse("hi")).toBe("hi")
const result = schema.safeParse("")
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toMatch(/./)
})
test("filter returning undefined passes validation", () => {
const alwaysOk = Schema.makeFilter(() => undefined)
const schema = zod(Schema.Number.check(alwaysOk))
expect(schema.parse(42)).toBe(42)
})
test("annotations.message on the filter is used when filter returns false", () => {
const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
const schema = zod(Schema.Number.check(positive))
const result = schema.safeParse(-1)
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toBe("must be positive")
})
test("cross-field check on a record flags missing key", () => {
const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
"required" in data ? undefined : "missing 'required' key",
)
const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
expect(schema.parse({ required: { enabled: true } })).toEqual({
required: { enabled: true },
})
const result = schema.safeParse({ other: { enabled: true } })
expect(result.success).toBe(false)
expect(result.error!.issues[0].message).toBe("missing 'required' key")
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"scripts": {
+5 -7
View File
@@ -1807,12 +1807,6 @@ export type Provider = {
}
}
export type ConsoleState = {
consoleManagedProviders: Array<string>
activeOrgName?: string
switchableOrgCount: number
}
export type ToolIds = Array<string>
export type ToolListItem = {
@@ -2939,7 +2933,11 @@ export type ExperimentalConsoleGetResponses = {
/**
* Active Console provider metadata
*/
200: ConsoleState
200: {
consoleManagedProviders: Array<string>
activeOrgName?: string
switchableOrgCount: number
}
}
export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
+30 -24
View File
@@ -1607,7 +1607,24 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConsoleState"
"type": "object",
"properties": {
"consoleManagedProviders": {
"type": "array",
"items": {
"type": "string"
}
},
"activeOrgName": {
"type": "string"
},
"switchableOrgCount": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"required": ["consoleManagedProviders", "switchableOrgCount"]
}
}
}
@@ -11411,10 +11428,13 @@
},
"timeout": {
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
"type": "number"
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": ["type", "command"]
"required": ["type", "command"],
"additionalProperties": false
},
"McpOAuthConfig": {
"type": "object",
@@ -11435,7 +11455,8 @@
"description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
"type": "string"
}
}
},
"additionalProperties": false
},
"McpRemoteConfig": {
"type": "object",
@@ -11477,10 +11498,13 @@
},
"timeout": {
"description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
"type": "number"
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": ["type", "url"]
"required": ["type", "url"],
"additionalProperties": false
},
"LayoutConfig": {
"description": "@deprecated Always uses stretch layout.",
@@ -12342,24 +12366,6 @@
},
"required": ["id", "name", "source", "env", "options", "models"]
},
"ConsoleState": {
"type": "object",
"properties": {
"consoleManagedProviders": {
"type": "array",
"items": {
"type": "string"
}
},
"activeOrgName": {
"type": "string"
},
"switchableOrgCount": {
"type": "number"
}
},
"required": ["consoleManagedProviders", "switchableOrgCount"]
},
"ToolIDs": {
"type": "array",
"items": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.11",
"version": "1.4.9",
"name": "@opencode-ai/shared",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.4.11",
"version": "1.4.9",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.4.11",
"version": "1.4.9",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.4.11",
"version": "1.4.9",
"publisher": "sst-dev",
"repository": {
"type": "git",