Merge branch 'dev' into sqlite2

This commit is contained in:
Dax Raad
2026-02-04 22:37:08 -05:00
138 changed files with 3658 additions and 1600 deletions
+3
View File
@@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
export namespace Agent {
export const Info = z
@@ -50,12 +51,14 @@ export namespace Agent {
const state = Instance.state(async () => {
const cfg = await Config.get()
const skillDirs = await Skill.dirs()
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
[Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
},
question: "deny",
plan_enter: "deny",
+13 -8
View File
@@ -6,6 +6,8 @@ import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -73,14 +75,17 @@ export namespace BunProc {
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
const modExists = await Filesystem.exists(mod)
if (dependencies[pkg] === version && modExists) return mod
const cachedVersion = dependencies[pkg]
const proxied = !!(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy
)
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
// Build command arguments
const args = [
@@ -88,7 +93,7 @@ export namespace BunProc {
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied ? ["--no-cache"] : []),
...(proxied() ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
+48
View File
@@ -0,0 +1,48 @@
import { readableStreamToText, semver } from "bun"
import { Log } from "../util/log"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
function which() {
return process.execPath
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const result = Bun.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
const code = await result.exited
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr })
return null
}
const value = stdout.trim()
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.order(cachedVersion, latestVersion) === -1
}
}
+6 -1
View File
@@ -277,6 +277,11 @@ export const RunCommand = cmd({
type: "string",
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
})
.option("thinking", {
type: "boolean",
describe: "show thinking blocks",
default: false,
})
},
handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])]
@@ -455,7 +460,7 @@ export const RunCommand = cmd({
UI.empty()
}
if (part.type === "reasoning" && part.time?.end) {
if (part.type === "reasoning" && part.time?.end && args.thinking) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
@@ -187,7 +187,6 @@ function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
Clipboard.setRenderer(renderer)
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
@@ -35,6 +35,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
@@ -48,6 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
theme.warning,
theme.primary,
theme.error,
theme.info,
])
return {
list() {
@@ -75,11 +77,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
color(name: string) {
const all = sync.data.agent
const agent = all.find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color)
const index = all.findIndex((x) => x.name === name)
const index = visibleAgents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
const agent = visibleAgents()[index]
if (agent?.color) {
const color = agent.color
if (color.startsWith("#")) return RGBA.fromHex(color)
// already validated by config, just satisfying TS here
return theme[color as keyof typeof theme] as RGBA
}
return colors()[index % colors().length]
},
}
@@ -546,6 +546,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
keybind: "display_thinking",
category: "Session",
slash: {
name: "thinking",
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
@@ -19,6 +19,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
@@ -269,7 +270,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
backgroundColor={
isActive()
? theme.accent
: tabHover() === index()
? theme.backgroundElement
: theme.backgroundPanel
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => selectTab(index())}
>
<text
@@ -290,7 +299,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={confirm() ? theme.accent : theme.backgroundElement}
backgroundColor={
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => selectTab(questions().length)}
>
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
@@ -1,12 +1,23 @@
import { $ } from "bun"
import type { CliRenderer } from "@opentui/core"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
const rendererRef = { current: undefined as CliRenderer | undefined }
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
@@ -14,10 +25,6 @@ export namespace Clipboard {
mime: string
}
export function setRenderer(renderer: CliRenderer | undefined): void {
rendererRef.current = renderer
}
export async function read(): Promise<Content | undefined> {
const os = platform()
@@ -146,11 +153,7 @@ export namespace Clipboard {
})
export async function copy(text: string): Promise<void> {
const renderer = rendererRef.current
if (renderer) {
const copied = renderer.copyToClipboardOSC52(text)
if (copied) return
}
writeOsc52(text)
await getCopyMethod()(text)
}
}
+74 -9
View File
@@ -24,10 +24,12 @@ import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs"
import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -154,9 +156,10 @@ export namespace Config {
}
}
const exists = existsSync(path.join(dir, "node_modules"))
const installing = installDependencies(dir)
if (!exists) await installing
const shouldInstall = await needsInstall(dir)
if (shouldInstall) {
await installDependencies(dir)
}
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
@@ -235,6 +238,7 @@ export namespace Config {
export async function installDependencies(dir: string) {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (!(await Bun.file(pkg).exists())) {
await Bun.write(pkg, "{}")
@@ -245,7 +249,13 @@ export namespace Config {
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
await BunProc.run(
["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
[
"add",
`@opencode-ai/plugin@${targetVersion}`,
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() ? ["--no-cache"] : []),
],
{
cwd: dir,
},
@@ -253,7 +263,59 @@ export namespace Config {
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch(() => {})
}
async function isWritable(dir: string) {
try {
await fs.access(dir, constants.W_OK)
return true
} catch {
return false
}
}
async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgFile = Bun.file(pkg)
const pkgExists = await pkgFile.exists()
if (!pkgExists) return true
const parsed = await pkgFile.json().catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) {
@@ -617,10 +679,12 @@ export namespace Config {
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code for the agent (e.g., #FF5733)"),
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
@@ -835,6 +899,7 @@ export namespace Config {
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
})
.strict()
.meta({
+2
View File
@@ -23,6 +23,8 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string
+2 -2
View File
@@ -732,7 +732,7 @@ export namespace LSPServer {
export const CSharp: Info = {
id: "csharp",
root: NearestRoot([".sln", ".csproj", "global.json"]),
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = Bun.which("csharp-ls", {
@@ -772,7 +772,7 @@ export namespace LSPServer {
export const FSharp: Info = {
id: "fsharp",
root: NearestRoot([".sln", ".fsproj", "global.json"]),
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = Bun.which("fsautocomplete", {
+46 -37
View File
@@ -239,7 +239,9 @@ export namespace Provider {
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
// Models from models.dev may already include prefixes like us., eu., global., etc.
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
return sdk.languageModel(modelID)
}
@@ -455,58 +457,65 @@ export namespace Provider {
},
}
},
"cloudflare-workers-ai": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
if (!accountId) return { autoload: false }
const apiKey = await iife(async () => {
const envToken = Env.get("CLOUDFLARE_API_KEY")
if (envToken) return envToken
const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key
return undefined
})
return {
autoload: !!apiKey,
options: {
apiKey,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
},
}
},
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
if (!accountId || !gateway) return { autoload: false }
// Get API token from env or auth prompt
// Get API token from env or auth - required for authenticated gateways
const apiToken = await (async () => {
const envToken = Env.get("CLOUDFLARE_API_TOKEN")
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
if (envToken) return envToken
const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key
return undefined
})()
if (!apiToken) {
throw new Error(
"CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
"Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
)
}
// Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
const { createAiGateway } = await import("ai-gateway-provider")
const { createUnified } = await import("ai-gateway-provider/providers/unified")
const aigateway = createAiGateway({ accountId, gateway, apiKey: apiToken })
const unified = createUnified()
return {
autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.languageModel(modelID)
},
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
headers: {
// Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
// This enables Unified Billing where Cloudflare handles upstream provider auth
...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
// Custom fetch to handle parameter transformation and auth
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
// Strip Authorization header - AI Gateway uses cf-aig-authorization instead
headers.delete("Authorization")
// Transform max_tokens to max_completion_tokens for newer models
if (init?.body && init.method === "POST") {
try {
const body = JSON.parse(init.body as string)
if (body.max_tokens !== undefined && !body.max_completion_tokens) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch (e) {
// If body parsing fails, continue with original request
}
}
return fetch(input, { ...init, headers })
},
async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
// Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
return aigateway(unified(modelID))
},
options: {},
}
},
cerebras: async () => {
+27 -5
View File
@@ -1,5 +1,6 @@
import type { APICallError, ModelMessage } from "ai"
import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
@@ -333,7 +334,9 @@ export namespace ProviderTransform {
id.includes("minimax") ||
id.includes("glm") ||
id.includes("mistral") ||
id.includes("kimi")
id.includes("kimi") ||
// TODO: Remove this after models.dev data is fixed to use "kimi-k2.5" instead of "k2p5"
id.includes("k2p5")
)
return {}
@@ -627,6 +630,18 @@ export namespace ProviderTransform {
}
}
// Enable thinking by default for kimi-k2.5/k2p5 models using anthropic SDK
const modelId = input.model.api.id.toLowerCase()
if (
(input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") &&
(modelId.includes("k2p5") || modelId.includes("kimi-k2.5") || modelId.includes("kimi-k2p5"))
) {
result["thinking"] = {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)),
}
}
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
@@ -717,7 +732,7 @@ export namespace ProviderTransform {
return standardLimit
}
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) {
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
/*
if (["openai", "azure"].includes(providerID)) {
if (schema.type === "object" && schema.properties) {
@@ -768,8 +783,15 @@ export namespace ProviderTransform {
result.required = result.required.filter((field: any) => field in result.properties)
}
if (result.type === "array" && result.items == null) {
result.items = {}
if (result.type === "array") {
if (result.items == null) {
result.items = {}
}
// Ensure items has at least a type if it's an empty object
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
result.items.type = "string"
}
}
// Remove properties/required from non-object types (Gemini rejects these)
@@ -784,7 +806,7 @@ export namespace ProviderTransform {
schema = sanitizeGemini(schema)
}
return schema
return schema as JSONSchema7
}
export function error(providerID: string, error: APICallError) {
+3
View File
@@ -8,6 +8,7 @@ import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
export namespace Pty {
const log = Log.create({ service: "pty" })
@@ -102,9 +103,11 @@ export namespace Pty {
}
const cwd = input.cwd || Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shellEnv.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
+5 -4
View File
@@ -17,13 +17,14 @@ const FILES = [
]
function globalFiles() {
const files = [path.join(Global.Path.config, "AGENTS.md")]
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
const files = []
if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md"))
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
return files
}
+12 -5
View File
@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
import { Session } from "."
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
@@ -736,6 +736,8 @@ export namespace SessionPrompt {
const execute = item.execute
if (!execute) continue
const transformed = ProviderTransform.schema(input.model, asSchema(item.inputSchema).jsonSchema)
item.inputSchema = jsonSchema(transformed)
// Wrap execute to add plugin hooks and format output
item.execute = async (args, opts) => {
const ctx = context(args, opts)
@@ -964,9 +966,11 @@ export namespace SessionPrompt {
// have to normalize, symbol search returns absolute paths
// Decode the pathname since URL constructor doesn't automatically decode it
const filepath = fileURLToPath(part.url)
const stat = await Bun.file(filepath).stat()
const stat = await Bun.file(filepath)
.stat()
.catch(() => undefined)
if (stat.isDirectory()) {
if (stat?.isDirectory()) {
part.mime = "application/x-directory"
}
@@ -985,7 +989,7 @@ export namespace SessionPrompt {
// workspace/symbol searches, so we'll try to find the
// symbol in the document to get the full range
if (start === end) {
const symbols = await LSP.documentSymbol(filePathURI)
const symbols = await LSP.documentSymbol(filePathURI).catch(() => [])
for (const symbol of symbols) {
let range: LSP.Range | undefined
if ("range" in symbol) {
@@ -1496,12 +1500,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const matchingInvocation = invocations[shellName] ?? invocations[""]
const args = matchingInvocation?.args
const cwd = Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const proc = spawn(shell, args, {
cwd: Instance.directory,
cwd,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
...shellEnv.env,
TERM: "dumb",
},
})
+46 -33
View File
@@ -40,12 +40,17 @@ export namespace Skill {
}),
)
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
export const state = Instance.state(async () => {
const skills: Record<string, Info> = {}
const dirs = new Set<string>()
const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch((err) => {
@@ -71,6 +76,8 @@ export namespace Skill {
})
}
dirs.add(path.dirname(match))
skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
@@ -79,38 +86,37 @@ export namespace Skill {
}
}
// Scan .claude/skills/ directories (project-level)
const claudeDirs = await Array.fromAsync(
Filesystem.up({
targets: [".claude"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
// Also include global ~/.claude/skills/
const globalClaude = `${Global.Path.home}/.claude`
if (await Filesystem.isDir(globalClaude)) {
claudeDirs.push(globalClaude)
const scanExternal = async (root: string, scope: "global" | "project") => {
return Array.fromAsync(
EXTERNAL_SKILL_GLOB.scan({
cwd: root,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
)
.then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
})
}
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
for (const dir of claudeDirs) {
const matches = await Array.fromAsync(
CLAUDE_SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
).catch((error) => {
log.error("failed .claude directory scan for skills", { dir, error })
return []
})
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
// Load global (home) first, then project-level (so project-level overwrites)
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
for (const dir of EXTERNAL_DIRS) {
const root = path.join(Global.Path.home, dir)
if (!(await Filesystem.isDir(root))) continue
await scanExternal(root, "global")
}
for (const match of matches) {
await addSkill(match)
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: Instance.directory,
stop: Instance.worktree,
})) {
await scanExternal(root, "project")
}
}
@@ -145,14 +151,21 @@ export namespace Skill {
}
}
return skills
return {
skills,
dirs: Array.from(dirs),
}
})
export async function get(name: string) {
return state().then((x) => x[name])
return state().then((x) => x.skills[name])
}
export async function all() {
return state().then((x) => Object.values(x))
return state().then((x) => Object.values(x.skills))
}
export async function dirs() {
return state().then((x) => x.dirs)
}
}
+3
View File
@@ -16,6 +16,7 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -162,11 +163,13 @@ export const BashTool = Tool.define("bash", async () => {
})
}
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const proc = spawn(params.command, {
shell,
cwd,
env: {
...process.env,
...shellEnv.env,
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
+5 -5
View File
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./codesearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
},
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
try {
const headers: Record<string, string> = {
@@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", {
method: "POST",
headers,
body: JSON.stringify(codeRequest),
signal: AbortSignal.any([controller.signal, ctx.abort]),
signal,
})
clearTimeout(timeoutId)
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
@@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
metadata: {},
}
} catch (error) {
clearTimeout(timeoutId)
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Code search request timed out")
+55 -12
View File
@@ -1,8 +1,11 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { PermissionNext } from "../permission/next"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => {
const skills = await Skill.all()
@@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const description =
accessibleSkills.length === 0
? "Load a skill to get detailed instructions for a specific task. No skills are currently available."
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a skill to get detailed instructions for a specific task.",
"Skills provide specialized knowledge and step-by-step guidance.",
"Use this when a task matches an available skill's description.",
"Only the skills listed here are available:",
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
"<available_skills>",
...accessibleSkills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
].join(" ")
].join("\n")
const examples = accessibleSkills
.map((skill) => `'${skill.name}'`)
@@ -41,7 +52,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The skill identifier from available_skills${hint}`),
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return {
@@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
always: [params.name],
metadata: {},
})
const content = skill.content
const dir = path.dirname(skill.location)
// Format output similar to plugin pattern
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n")
const dir = path.dirname(skill.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
return {
title: `Loaded skill: ${skill.name}`,
output,
output: [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: skill.name,
dir,
+3 -5
View File
@@ -2,6 +2,7 @@ import z from "zod"
import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -36,8 +37,7 @@ export const WebFetchTool = Tool.define("webfetch", {
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
@@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", {
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const signal = AbortSignal.any([controller.signal, ctx.abort])
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
@@ -72,7 +70,7 @@ export const WebFetchTool = Tool.define("webfetch", {
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
clearTimeout(timeoutId)
clearTimeout()
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`)
+5 -5
View File
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./websearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
},
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 25000)
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
try {
const headers: Record<string, string> = {
@@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => {
method: "POST",
headers,
body: JSON.stringify(searchRequest),
signal: AbortSignal.any([controller.signal, ctx.abort]),
signal,
})
clearTimeout(timeoutId)
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
@@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
metadata: {},
}
} catch (error) {
clearTimeout(timeoutId)
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Search request timed out")
+35
View File
@@ -0,0 +1,35 @@
/**
* Creates an AbortController that automatically aborts after a timeout.
*
* Uses bind() instead of arrow functions to avoid capturing the surrounding
* scope in closures. Arrow functions like `() => controller.abort()` capture
* request bodies and other large objects, preventing GC for the timer lifetime.
*
* @param ms Timeout in milliseconds
* @returns Object with controller, signal, and clearTimeout function
*/
export function abortAfter(ms: number) {
const controller = new AbortController()
const id = setTimeout(controller.abort.bind(controller), ms)
return {
controller,
signal: controller.signal,
clearTimeout: () => globalThis.clearTimeout(id),
}
}
/**
* Combines multiple AbortSignals with a timeout.
*
* @param ms Timeout in milliseconds
* @param signals Additional signals to combine
* @returns Combined signal that aborts on timeout or when any input signal aborts
*/
export function abortAfterAny(ms: number, ...signals: AbortSignal[]) {
const timeout = abortAfter(ms)
const signal = AbortSignal.any([timeout.signal, ...signals])
return {
signal,
clearTimeout: timeout.clearTimeout,
}
}
+3
View File
@@ -0,0 +1,3 @@
export function proxied() {
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
}