Merge branch 'dev' into sqlite2
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function proxied() {
|
||||
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
|
||||
}
|
||||
Reference in New Issue
Block a user