summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--STATS.md4
-rw-r--r--cloud/app/package.json2
-rw-r--r--cloud/app/src/routes/zen/v1/chat/completions.ts119
-rw-r--r--cloud/core/package.json2
-rw-r--r--cloud/function/package.json2
-rw-r--r--cloud/function/src/log-processor.ts49
-rw-r--r--cloud/function/sst-env.d.ts5
-rw-r--r--cloud/resource/sst-env.d.ts5
-rw-r--r--cloud/scripts/package.json2
-rw-r--r--infra/cloud.ts16
-rw-r--r--packages/function/package.json2
-rw-r--r--packages/function/sst-env.d.ts5
-rw-r--r--packages/opencode/bin/opencode.cmd6
-rw-r--r--packages/opencode/package.json2
-rw-r--r--packages/opencode/script/postinstall.mjs5
-rw-r--r--packages/opencode/script/preinstall.mjs32
-rwxr-xr-xpackages/opencode/script/publish.ts2
-rw-r--r--packages/opencode/src/file/index.ts4
-rw-r--r--packages/opencode/src/index.ts6
-rw-r--r--packages/opencode/src/plugin/index.ts8
-rw-r--r--packages/opencode/src/provider/provider.ts42
-rw-r--r--packages/opencode/src/provider/transform.ts14
-rw-r--r--packages/opencode/src/server/server.ts118
-rw-r--r--packages/opencode/src/session/index.ts113
-rw-r--r--packages/opencode/src/tool/edit.ts9
-rw-r--r--packages/opencode/src/tool/edit.txt3
-rw-r--r--packages/opencode/src/tool/registry.ts111
-rw-r--r--packages/opencode/test/tool/register.test.ts299
-rw-r--r--packages/plugin/package.json2
-rw-r--r--packages/plugin/src/index.ts37
-rw-r--r--packages/sdk/js/package.json2
-rw-r--r--packages/sdk/js/src/gen/sdk.gen.ts46
-rw-r--r--packages/sdk/js/src/gen/types.gen.ts127
-rw-r--r--packages/tui/internal/components/chat/editor.go3
-rw-r--r--packages/web/package.json2
-rw-r--r--sdks/vscode/package.json2
-rw-r--r--sst-env.d.ts7
37 files changed, 1076 insertions, 139 deletions
diff --git a/STATS.md b/STATS.md
index 7ce8adb0c..ab0c16cd1 100644
--- a/STATS.md
+++ b/STATS.md
@@ -69,3 +69,7 @@
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
+| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
+| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
+| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
+| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
diff --git a/cloud/app/package.json b/cloud/app/package.json
index 6fa9a7672..4b30c3cff 100644
--- a/cloud/app/package.json
+++ b/cloud/app/package.json
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../packages/opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
- "version": "0.6.4"
+ "version": "0.6.6"
},
"dependencies": {
"@ibm/plex": "6.4.1",
diff --git a/cloud/app/src/routes/zen/v1/chat/completions.ts b/cloud/app/src/routes/zen/v1/chat/completions.ts
index 9805176ce..9765ea08f 100644
--- a/cloud/app/src/routes/zen/v1/chat/completions.ts
+++ b/cloud/app/src/routes/zen/v1/chat/completions.ts
@@ -22,7 +22,7 @@ const MODELS = {
// headerMappings: {},
// },
"qwen/qwen3-coder": {
- id: "qwen/qwen3-coder",
+ id: "qwen/qwen3-coder" as const,
auth: true,
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
@@ -37,7 +37,7 @@ const MODELS = {
headerMappings: {},
},
"grok-code": {
- id: "x-ai/grok-code-fast-1",
+ id: "x-ai/grok-code-fast-1" as const,
auth: false,
api: "https://api.x.ai",
apiKey: Resource.XAI_API_KEY.value,
@@ -68,6 +68,11 @@ export async function POST(input: APIEvent) {
try {
const url = new URL(input.request.url)
const body = await input.request.json()
+ logMetric({
+ is_tream: !!body.stream,
+ session: input.request.headers.get("x-opencode-session"),
+ request: input.request.headers.get("x-opencode-request"),
+ })
const MODEL = validateModel()
const apiKey = await authenticate()
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
@@ -106,9 +111,11 @@ export async function POST(input: APIEvent) {
// Handle non-streaming response
if (!body.stream) {
- const body = await res.json()
- await trackUsage(body)
- return new Response(JSON.stringify(body), {
+ const json = await res.json()
+ const body = JSON.stringify(json)
+ logMetric({ response_length: body.length })
+ await trackUsage(json)
+ return new Response(body, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
@@ -121,16 +128,26 @@ export async function POST(input: APIEvent) {
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
+ let responseLength = 0
+ let startTimestamp = Date.now()
+ let receivedFirstByte = false
function pump(): Promise<void> {
return (
reader?.read().then(async ({ done, value }) => {
if (done) {
+ logMetric({ response_length: responseLength })
c.close()
return
}
+ if (!receivedFirstByte) {
+ receivedFirstByte = true
+ logMetric({ time_to_first_byte: Date.now() - startTimestamp })
+ }
+
buffer += decoder.decode(value, { stream: true })
+ responseLength += value.length
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
@@ -169,7 +186,9 @@ export async function POST(input: APIEvent) {
if (!(body.model in MODELS)) {
throw new ModelError(`Model ${body.model} not supported`)
}
- return MODELS[body.model as keyof typeof MODELS]
+ const model = MODELS[body.model as keyof typeof MODELS]
+ logMetric({ model: model.id })
+ return model
}
async function authenticate() {
@@ -190,9 +209,12 @@ export async function POST(input: APIEvent) {
)
if (!key) throw new AuthError("Invalid API key.")
+ logMetric({
+ api_key: key.id,
+ workspace: key.workspaceID,
+ })
return key
} catch (e) {
- console.log(e)
// ignore error if model does not require authentication
if (!MODEL.auth) return
throw e
@@ -216,10 +238,6 @@ export async function POST(input: APIEvent) {
}
async function trackUsage(chunk: any) {
- console.log(`trackUsage ${apiKey}`)
-
- if (!apiKey) return
-
const usage = chunk.usage
const inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
@@ -228,14 +246,30 @@ export async function POST(input: APIEvent) {
//const cacheWriteTokens = providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? 0
const cacheWriteTokens = 0
- const inputCost = MODEL.cost.input * inputTokens
- const outputCost = MODEL.cost.output * outputTokens
- const reasoningCost = MODEL.cost.reasoning * reasoningTokens
- const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens
- const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens
- const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100
- const cost = isFree ? 0 : centsToMicroCents(costInCents)
+ const inputCost = MODEL.cost.input * inputTokens * 100
+ const outputCost = MODEL.cost.output * outputTokens * 100
+ const reasoningCost = MODEL.cost.reasoning * reasoningTokens * 100
+ const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens * 100
+ const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens * 100
+ const totalCostInCent = inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost
+
+ logMetric({
+ "tokens.input": inputTokens,
+ "tokens.output": outputTokens,
+ "tokens.reasoning": reasoningTokens,
+ "tokens.cache_read": cacheReadTokens,
+ "tokens.cache_write": cacheWriteTokens,
+ "cost.input": Math.round(inputCost),
+ "cost.output": Math.round(outputCost),
+ "cost.reasoning": Math.round(reasoningCost),
+ "cost.cache_read": Math.round(cacheReadCost),
+ "cost.cache_write": Math.round(cacheWriteCost),
+ "cost.total": Math.round(totalCostInCent),
+ })
+ if (!apiKey) return
+
+ const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: apiKey.workspaceID,
@@ -264,47 +298,18 @@ export async function POST(input: APIEvent) {
)
}
} catch (error: any) {
- if (error instanceof AuthError) {
- return new Response(
- JSON.stringify({
- error: {
- message: error.message,
- type: "invalid_request_error",
- param: null,
- code: "unauthorized",
- },
- }),
- {
- status: 401,
- },
- )
- }
+ logMetric({
+ "error.type": error.constructor.name,
+ "error.message": error.message,
+ })
- if (error instanceof CreditsError) {
- return new Response(
- JSON.stringify({
- error: {
- message: error.message,
- type: "insufficient_quota",
- param: null,
- code: "insufficient_quota",
- },
- }),
- {
- status: 401,
- },
- )
- }
+ if (error instanceof AuthError || error instanceof CreditsError || error instanceof ModelError)
+ return new Response(JSON.stringify({ error: { message: error.message } }), { status: 401 })
- if (error instanceof ModelError) {
- return new Response(JSON.stringify({ error: { message: error.message } }), {
- status: 401,
- })
- }
+ return new Response(JSON.stringify({ error: { message: error.message } }), { status: 500 })
+ }
- console.log(error)
- return new Response(JSON.stringify({ error: { message: error.message } }), {
- status: 500,
- })
+ function logMetric(values: Record<string, any>) {
+ console.log(`_metric:${JSON.stringify(values)}`)
}
}
diff --git a/cloud/core/package.json b/cloud/core/package.json
index adc0776e2..c32a08abf 100644
--- a/cloud/core/package.json
+++ b/cloud/core/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
- "version": "0.6.4",
+ "version": "0.6.6",
"private": true,
"type": "module",
"dependencies": {
diff --git a/cloud/function/package.json b/cloud/function/package.json
index 14518efa6..21aa49b80 100644
--- a/cloud/function/package.json
+++ b/cloud/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
- "version": "0.6.4",
+ "version": "0.6.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/cloud/function/src/log-processor.ts b/cloud/function/src/log-processor.ts
new file mode 100644
index 000000000..70fcf5a44
--- /dev/null
+++ b/cloud/function/src/log-processor.ts
@@ -0,0 +1,49 @@
+import { Resource } from "@opencode/cloud-resource"
+import type { TraceItem } from "@cloudflare/workers-types"
+
+export default {
+ async tail(events: TraceItem[]) {
+ for (const event of events) {
+ if (!event.event) continue
+ if (!("request" in event.event)) continue
+ if (event.event.request.method !== "POST") continue
+
+ const url = new URL(event.event.request.url)
+ if (url.pathname !== "/zen/v1/chat/completions") return
+
+ let metrics = {
+ event_type: "completions",
+ "cf.continent": event.event.request.cf?.continent,
+ "cf.country": event.event.request.cf?.country,
+ "cf.city": event.event.request.cf?.city,
+ "cf.region": event.event.request.cf?.region,
+ "cf.latitude": event.event.request.cf?.latitude,
+ "cf.longitude": event.event.request.cf?.longitude,
+ "cf.timezone": event.event.request.cf?.timezone,
+ duration: event.wallTime,
+ request_length: parseInt(event.event.request.headers["content-length"] ?? "0"),
+ status: event.event.response?.status ?? 0,
+ ip: event.event.request.headers["x-real-ip"],
+ }
+ for (const log of event.logs) {
+ for (const message of log.message) {
+ if (!message.startsWith("_metric:")) continue
+ metrics = { ...metrics, ...JSON.parse(message.slice(8)) }
+ }
+ }
+ console.log(JSON.stringify(metrics, null, 2))
+
+ const ret = await fetch("https://api.honeycomb.io/1/events/zen", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Honeycomb-Event-Time": (event.eventTimestamp ?? Date.now()).toString(),
+ "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
+ },
+ body: JSON.stringify(metrics),
+ })
+ console.log(ret.status)
+ console.log(await ret.text())
+ }
+ },
+}
diff --git a/cloud/function/sst-env.d.ts b/cloud/function/sst-env.d.ts
index 5478b4334..efb047ff5 100644
--- a/cloud/function/sst-env.d.ts
+++ b/cloud/function/sst-env.d.ts
@@ -50,6 +50,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "HONEYCOMB_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -76,6 +80,7 @@ declare module "sst" {
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
+ "LogProcessor": cloudflare.Service
}
}
diff --git a/cloud/resource/sst-env.d.ts b/cloud/resource/sst-env.d.ts
index 5478b4334..efb047ff5 100644
--- a/cloud/resource/sst-env.d.ts
+++ b/cloud/resource/sst-env.d.ts
@@ -50,6 +50,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "HONEYCOMB_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -76,6 +80,7 @@ declare module "sst" {
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
+ "LogProcessor": cloudflare.Service
}
}
diff --git a/cloud/scripts/package.json b/cloud/scripts/package.json
index 85896ea4e..0a773fc45 100644
--- a/cloud/scripts/package.json
+++ b/cloud/scripts/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-scripts",
- "version": "0.6.4",
+ "version": "0.6.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/infra/cloud.ts b/infra/cloud.ts
index 74690669e..9cb5a83f1 100644
--- a/infra/cloud.ts
+++ b/infra/cloud.ts
@@ -7,7 +7,7 @@ import { domain } from "./stage"
const cluster = planetscale.getDatabaseOutput({
name: "opencode",
- organization: "sst",
+ organization: "anomalyco",
})
const branch =
@@ -114,6 +114,15 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
// CONSOLE
////////////////
+let logProcessor
+if ($app.stage === "production" || $app.stage === "frank") {
+ const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
+ logProcessor = new sst.cloudflare.Worker("LogProcessor", {
+ handler: "cloud/function/src/log-processor.ts",
+ link: [HONEYCOMB_API_KEY],
+ })
+}
+
new sst.cloudflare.x.SolidStart("Console", {
domain,
path: "cloud/app",
@@ -135,9 +144,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
transform: {
worker: {
- placement: {
- mode: "smart",
- },
+ placement: { mode: "smart" },
+ tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
},
},
},
diff --git a/packages/function/package.json b/packages/function/package.json
index c5a55f8e2..76ebfcbc1 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
- "version": "0.6.4",
+ "version": "0.6.6",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index 5478b4334..efb047ff5 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -50,6 +50,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "HONEYCOMB_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -76,6 +80,7 @@ declare module "sst" {
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
+ "LogProcessor": cloudflare.Service
}
}
diff --git a/packages/opencode/bin/opencode.cmd b/packages/opencode/bin/opencode.cmd
index 3a4ef3e72..775bfe688 100644
--- a/packages/opencode/bin/opencode.cmd
+++ b/packages/opencode/bin/opencode.cmd
@@ -52,5 +52,7 @@ echo It seems that your package manager failed to install the right version of t
exit /b 1
:execute
-rem Execute the binary with all arguments
-"%resolved%" %*
+rem Execute the binary with all arguments in the same console window
+rem Use start /b /wait to ensure it runs in the current shell context for all shells
+start /b /wait "" "%resolved%" %*
+exit /b %ERRORLEVEL%
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 8939d106f..65c08641d 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "0.6.4",
+ "version": "0.6.6",
"name": "opencode",
"type": "module",
"private": true,
diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs
index 2c6974123..40796562c 100644
--- a/packages/opencode/script/postinstall.mjs
+++ b/packages/opencode/script/postinstall.mjs
@@ -70,6 +70,11 @@ function findBinary() {
function main() {
try {
+ if (os.platform() === "win32") {
+ console.log("Windows detected, skipping postinstall")
+ return
+ }
+
const binaryPath = findBinary()
const binScript = path.join(__dirname, "bin", "opencode")
diff --git a/packages/opencode/script/preinstall.mjs b/packages/opencode/script/preinstall.mjs
new file mode 100644
index 000000000..49c8db5e5
--- /dev/null
+++ b/packages/opencode/script/preinstall.mjs
@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+
+import fs from "fs"
+import path from "path"
+import os from "os"
+import { fileURLToPath } from "url"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+function main() {
+ if (os.platform() !== "win32") {
+ console.log("Non-Windows platform detected, skipping preinstall")
+ return
+ }
+
+ const binDir = path.join(__dirname, "bin")
+ const unixScript = path.join(binDir, "opencode")
+
+ console.log("Windows detected: Configuring bin scripts for Windows")
+
+ if (fs.existsSync(unixScript)) {
+ console.log("Removing Unix shell script from bin/")
+ fs.unlinkSync(unixScript)
+ }
+}
+
+try {
+ main()
+} catch (error) {
+ console.error("Preinstall script error:", error.message)
+ process.exit(0)
+}
diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts
index 33ddeff05..0de48fe67 100755
--- a/packages/opencode/script/publish.ts
+++ b/packages/opencode/script/publish.ts
@@ -66,6 +66,7 @@ for (const [os, arch] of targets) {
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
+await $`cp ./script/preinstall.mjs ./dist/${pkg.name}/preinstall.mjs`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
@@ -75,6 +76,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
[pkg.name]: `./bin/${pkg.name}`,
},
scripts: {
+ preinstall: "node ./preinstall.mjs",
postinstall: "node ./postinstall.mjs",
},
version,
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index 457890d54..dda220be4 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -156,10 +156,10 @@ export namespace File {
const diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
- const diff = structuredPatch(file, file, original, content, "old", "new", {
+ const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
})
- const patch = formatPatch(diff)
+ const diff = formatPatch(patch)
return { content, patch, diff }
}
}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index e70692103..6e224abd7 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -84,7 +84,11 @@ const cli = yargs(hideBin(process.argv))
.command(ExportCommand)
.command(GithubCommand)
.fail((msg) => {
- if (msg.startsWith("Unknown argument") || msg.startsWith("Not enough non-option arguments")) {
+ if (
+ msg.startsWith("Unknown argument") ||
+ msg.startsWith("Not enough non-option arguments") ||
+ msg.startsWith("Invalid values:")
+ ) {
cli.showHelp("log")
}
process.exit(1)
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index a9e9803fc..0f0689b50 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -7,6 +7,7 @@ import { Server } from "../server/server"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
+import { ToolRegistry } from "../tool/registry"
export namespace Plugin {
const log = Log.create({ service: "plugin" })
@@ -24,6 +25,8 @@ export namespace Plugin {
worktree: Instance.worktree,
directory: Instance.directory,
$: Bun.$,
+ Tool: await import("../tool/tool").then(m => m.Tool),
+ z: await import("zod").then(m => m.z),
}
const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
@@ -75,6 +78,11 @@ export namespace Plugin {
const config = await Config.get()
for (const hook of hooks) {
await hook.config?.(config)
+ // Let plugins register tools at startup
+ await hook["tool.register"]?.({}, {
+ registerHTTP: ToolRegistry.registerHTTP,
+ register: ToolRegistry.register
+ })
}
Bus.subscribeAll(async (input) => {
const hooks = await state().then((x) => x.hooks)
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index 184c2c950..37d7aa7c0 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -1,4 +1,5 @@
import z from "zod"
+import path from "path"
import { Config } from "../config/config"
import { mergeDeep, sortBy } from "remeda"
import { NoSuchModelError, type LanguageModel, type Provider as SDK } from "ai"
@@ -9,6 +10,7 @@ import { ModelsDev } from "./models"
import { NamedError } from "../util/error"
import { Auth } from "../auth"
import { Instance } from "../project/instance"
+import { Global } from "../global"
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -79,7 +81,8 @@ export namespace Provider {
switch (regionPrefix) {
case "us": {
const modelRequiresPrefix = ["claude", "deepseek"].some((m) => modelID.includes(m))
- if (modelRequiresPrefix) {
+ const isGovCloud = region.startsWith("us-gov")
+ if (modelRequiresPrefix && !isGovCloud) {
modelID = `${regionPrefix}.${modelID}`
}
break
@@ -420,6 +423,43 @@ export namespace Provider {
export async function defaultModel() {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
+
+ // this will be adjusted when migration to opentui is complete,
+ // for now we just read the tui state toml file directly
+ //
+ // NOTE: cannot just import file as toml without cleaning due to lack of
+ // support for date/time references in Bun toml parser: https://github.com/oven-sh/bun/issues/22426
+ const lastused = await Bun.file(path.join(Global.Path.state, "tui"))
+ .text()
+ .then((text) => {
+ // remove the date/time references since Bun toml parser doesn't support yet
+ const cleaned = text
+ .split("\n")
+ .filter((line) => !line.trim().startsWith("last_used ="))
+ .join("\n")
+ const state = Bun.TOML.parse(cleaned) as {
+ recently_used_models?: {
+ provider_id: string
+ model_id: string
+ }[]
+ }
+ const models = state?.recently_used_models ?? []
+ if (models.length > 0) {
+ return {
+ providerID: models[0].provider_id,
+ modelID: models[0].model_id,
+ }
+ }
+ })
+ .catch((error) => {
+ log.error("failed to find last used model", {
+ error,
+ })
+ return undefined
+ })
+
+ if (lastused) return lastused
+
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 50aa55cba..a9616cfd1 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -98,4 +98,18 @@ export namespace ProviderTransform {
}
return result
}
+
+ export function maxOutputTokens(providerID: string, outputLimit: number, options: Record<string, any>): number {
+ if (providerID === "anthropic") {
+ const thinking = options["thinking"]
+ if (typeof thinking === "object" && thinking !== null) {
+ const type = thinking["type"]
+ const budgetTokens = thinking["budgetTokens"]
+ if (type === "enabled" && typeof budgetTokens === "number" && budgetTokens > 0) {
+ return outputLimit - budgetTokens
+ }
+ }
+ }
+ return outputLimit
+ }
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 9091f0508..108a67ec0 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -23,6 +23,8 @@ import { Auth } from "../auth"
import { Command } from "../command"
import { Global } from "../global"
import { ProjectRoute } from "./project"
+import { ToolRegistry } from "../tool/registry"
+import { zodToJsonSchema } from "zod-to-json-schema"
const ERRORS = {
400: {
@@ -46,6 +48,29 @@ const ERRORS = {
export namespace Server {
const log = Log.create({ service: "server" })
+ // Schemas for HTTP tool registration
+ const HttpParamSpec = z
+ .object({
+ type: z.enum(["string", "number", "boolean", "array"]),
+ description: z.string().optional(),
+ optional: z.boolean().optional(),
+ items: z.enum(["string", "number", "boolean"]).optional(),
+ })
+ .openapi({ ref: "HttpParamSpec" })
+
+ const HttpToolRegistration = z
+ .object({
+ id: z.string(),
+ description: z.string(),
+ parameters: z.object({
+ type: z.literal("object"),
+ properties: z.record(HttpParamSpec),
+ }),
+ callbackUrl: z.string(),
+ headers: z.record(z.string(), z.string()).optional(),
+ })
+ .openapi({ ref: "HttpToolRegistration" })
+
export const Event = {
Connected: Bus.event("server.connected", z.object({})),
}
@@ -166,6 +191,99 @@ export namespace Server {
return c.json(await Config.get())
},
)
+ .post(
+ "/experimental/tool/register",
+ describeRoute({
+ description: "Register a new HTTP callback tool",
+ operationId: "tool.register",
+ responses: {
+ 200: {
+ description: "Tool registered successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...ERRORS,
+ },
+ }),
+ zValidator("json", HttpToolRegistration),
+ async (c) => {
+ ToolRegistry.registerHTTP(c.req.valid("json"))
+ return c.json(true)
+ },
+ )
+ .get(
+ "/experimental/tool/ids",
+ describeRoute({
+ description: "List all tool IDs (including built-in and dynamically registered)",
+ operationId: "tool.ids",
+ responses: {
+ 200: {
+ description: "Tool IDs",
+ content: {
+ "application/json": {
+ schema: resolver(z.array(z.string()).openapi({ ref: "ToolIDs" })),
+ },
+ },
+ },
+ ...ERRORS,
+ },
+ }),
+ async (c) => {
+ return c.json(ToolRegistry.ids())
+ },
+ )
+ .get(
+ "/experimental/tool",
+ describeRoute({
+ description: "List tools with JSON schema parameters for a provider/model",
+ operationId: "tool.list",
+ responses: {
+ 200: {
+ description: "Tools",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z
+ .array(
+ z
+ .object({
+ id: z.string(),
+ description: z.string(),
+ parameters: z.any(),
+ })
+ .openapi({ ref: "ToolListItem" }),
+ )
+ .openapi({ ref: "ToolList" }),
+ ),
+ },
+ },
+ },
+ ...ERRORS,
+ },
+ }),
+ zValidator(
+ "query",
+ z.object({
+ provider: z.string(),
+ model: z.string(),
+ }),
+ ),
+ async (c) => {
+ const { provider, model } = c.req.valid("query")
+ const tools = await ToolRegistry.tools(provider, model)
+ return c.json(
+ tools.map((t) => ({
+ id: t.id,
+ description: t.description,
+ // Handle both Zod schemas and plain JSON schemas
+ parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
+ })),
+ )
+ },
+ )
.get(
"/path",
describeRoute({
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 1bee54426..2b49bb74a 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -365,6 +365,36 @@ export namespace Session {
return part
}
+ async function cleanupRevert(session: Info) {
+ if (!session.revert) return
+ const sessionID = session.id
+ let msgs = await messages(sessionID)
+ const messageID = session.revert.messageID
+ const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
+ msgs = preserve
+ for (const msg of remove) {
+ await Storage.remove(["message", sessionID, msg.info.id])
+ await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
+ }
+ const last = preserve.at(-1)
+ if (session.revert.partID && last) {
+ const partID = session.revert.partID
+ const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
+ last.parts = preserveParts
+ for (const part of removeParts) {
+ await Storage.remove(["part", last.info.id, part.id])
+ await Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: sessionID,
+ messageID: last.info.id,
+ partID: part.id,
+ })
+ }
+ }
+ await update(sessionID, (draft) => {
+ draft.revert = undefined
+ })
+ }
+
export const PromptInput = z.object({
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message").optional(),
@@ -425,31 +455,7 @@ export namespace Session {
// Process revert cleanup first, before creating new messages
const session = await get(input.sessionID)
if (session.revert) {
- let msgs = await messages(input.sessionID)
- const messageID = session.revert.messageID
- const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
- msgs = preserve
- for (const msg of remove) {
- await Storage.remove(["message", input.sessionID, msg.info.id])
- await Bus.publish(MessageV2.Event.Removed, { sessionID: input.sessionID, messageID: msg.info.id })
- }
- const last = preserve.at(-1)
- if (session.revert.partID && last) {
- const partID = session.revert.partID
- const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
- last.parts = preserveParts
- for (const part of removeParts) {
- await Storage.remove(["part", last.info.id, part.id])
- await Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: input.sessionID,
- messageID: last.info.id,
- partID: part.id,
- })
- }
- }
- await update(input.sessionID, (draft) => {
- draft.revert = undefined
- })
+ cleanupRevert(session)
}
const userMsg: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
@@ -691,16 +697,27 @@ export namespace Session {
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
-
- if (msgs.filter((m) => m.info.role === "user").length === 1 && !session.parentID && isDefaultTitle(session.title)) {
+ const numRealUserMsgs = msgs.filter(
+ (m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic),
+ ).length
+ if (numRealUserMsgs === 1 && !session.parentID && isDefaultTitle(session.title)) {
const small = (await Provider.getSmallModel(model.providerID)) ?? model
+ const options = {
+ ...ProviderTransform.options(small.providerID, small.modelID, input.sessionID),
+ ...small.info.options,
+ }
+ if (small.providerID === "openai") {
+ options["reasoningEffort"] = "minimal"
+ }
+ if (small.providerID === "google") {
+ options["thinkingConfig"] = {
+ thinkingBudget: 0,
+ }
+ }
generateText({
- maxOutputTokens: small.info.reasoning ? 1024 : 20,
+ maxOutputTokens: small.info.reasoning ? 1500 : 20,
providerOptions: {
- [model.providerID]: {
- ...small.info.options,
- ...ProviderTransform.options(small.providerID, small.modelID, input.sessionID),
- },
+ [model.providerID]: options,
},
messages: [
...SystemPrompt.title(model.providerID).map(
@@ -1008,7 +1025,7 @@ export namespace Session {
: undefined,
maxRetries: 3,
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
- maxOutputTokens: outputLimit,
+ maxOutputTokens: ProviderTransform.maxOutputTokens(model.providerID, outputLimit, params.options),
abortSignal: abort.signal,
stopWhen: async ({ steps }) => {
if (steps.length >= 1000) {
@@ -1074,6 +1091,10 @@ export namespace Session {
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
using abort = lock(input.sessionID)
+ const session = await get(input.sessionID)
+ if (session.revert) {
+ cleanupRevert(session)
+ }
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
@@ -1297,18 +1318,26 @@ export namespace Session {
}),
)
+ const model = await (async () => {
+ if (command.model) {
+ return Provider.parseModel(command.model)
+ }
+ if (command.agent) {
+ const agent = await Agent.get(command.agent)
+ if (agent.model) {
+ return agent.model
+ }
+ }
+ if (input.model) {
+ return Provider.parseModel(input.model)
+ }
+ return undefined
+ })()
+
return prompt({
sessionID: input.sessionID,
messageID: input.messageID,
- model: (() => {
- if (input.model) {
- return Provider.parseModel(input.model)
- }
- if (command.model) {
- return Provider.parseModel(command.model)
- }
- return undefined
- })(),
+ model,
agent,
parts,
})
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 928188c07..f4fd87feb 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -594,6 +594,8 @@ export function replace(content: string, oldString: string, newString: string, r
throw new Error("oldString and newString must be different")
}
+ let notFound = true
+
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
@@ -608,6 +610,7 @@ export function replace(content: string, oldString: string, newString: string, r
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
+ notFound = false
if (replaceAll) {
return content.replaceAll(search, newString)
}
@@ -616,5 +619,9 @@ export function replace(content: string, oldString: string, newString: string, r
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
- throw new Error("oldString not found in content or was found multiple times")
+
+ if (notFound) {
+ throw new Error("oldString not found in content")
+ }
+ throw new Error("oldString found multiple times and requires more code context to uniquely identify the intended match")
}
diff --git a/packages/opencode/src/tool/edit.txt b/packages/opencode/src/tool/edit.txt
index 8bf8844d3..863efb840 100644
--- a/packages/opencode/src/tool/edit.txt
+++ b/packages/opencode/src/tool/edit.txt
@@ -5,5 +5,6 @@ Usage:
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
-- The edit will FAIL if `oldString` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
+- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
+- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index c25b16ed3..f1211bf95 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -12,9 +12,11 @@ import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import type { Agent } from "../agent/agent"
+import { Tool } from "./tool"
export namespace ToolRegistry {
- const ALL = [
+ // Built-in tools that ship with opencode
+ const BUILTIN = [
InvalidTool,
BashTool,
EditTool,
@@ -30,13 +32,103 @@ export namespace ToolRegistry {
TaskTool,
]
+ // Extra tools registered at runtime (via plugins)
+ const EXTRA: Tool.Info[] = []
+
+ // Tools registered via HTTP callback (via SDK/API)
+ const HTTP: Tool.Info[] = []
+
+ export type HttpParamSpec = {
+ type: "string" | "number" | "boolean" | "array"
+ description?: string
+ optional?: boolean
+ items?: "string" | "number" | "boolean"
+ }
+ export type HttpToolRegistration = {
+ id: string
+ description: string
+ parameters: {
+ type: "object"
+ properties: Record<string, HttpParamSpec>
+ }
+ callbackUrl: string
+ headers?: Record<string, string>
+ }
+
+ function buildZodFromHttpSpec(spec: HttpToolRegistration["parameters"]) {
+ const shape: Record<string, z.ZodTypeAny> = {}
+ for (const [key, val] of Object.entries(spec.properties)) {
+ let base: z.ZodTypeAny
+ switch (val.type) {
+ case "string":
+ base = z.string()
+ break
+ case "number":
+ base = z.number()
+ break
+ case "boolean":
+ base = z.boolean()
+ break
+ case "array":
+ if (!val.items) throw new Error(`array spec for ${key} requires 'items'`)
+ base = z.array(
+ val.items === "string" ? z.string() : val.items === "number" ? z.number() : z.boolean(),
+ )
+ break
+ default:
+ base = z.any()
+ }
+ if (val.description) base = base.describe(val.description)
+ shape[key] = val.optional ? base.optional() : base
+ }
+ return z.object(shape)
+ }
+
+ export function register(tool: Tool.Info) {
+ // Prevent duplicates by id (replace existing)
+ const idx = EXTRA.findIndex((t) => t.id === tool.id)
+ if (idx >= 0) EXTRA.splice(idx, 1, tool)
+ else EXTRA.push(tool)
+ }
+
+ export function registerHTTP(input: HttpToolRegistration) {
+ const parameters = buildZodFromHttpSpec(input.parameters)
+ const info = Tool.define(input.id, {
+ description: input.description,
+ parameters,
+ async execute(args) {
+ const res = await fetch(input.callbackUrl, {
+ method: "POST",
+ headers: { "content-type": "application/json", ...(input.headers ?? {}) },
+ body: JSON.stringify({ args }),
+ })
+ if (!res.ok) {
+ throw new Error(`HTTP tool callback failed: ${res.status} ${await res.text()}`)
+ }
+ const json = (await res.json()) as { title?: string; output: string; metadata?: Record<string, any> }
+ return {
+ title: json.title ?? input.id,
+ output: json.output ?? "",
+ metadata: (json.metadata ?? {}) as any,
+ }
+ },
+ })
+ const idx = HTTP.findIndex((t) => t.id === info.id)
+ if (idx >= 0) HTTP.splice(idx, 1, info)
+ else HTTP.push(info)
+ }
+
+ function allTools(): Tool.Info[] {
+ return [...BUILTIN, ...EXTRA, ...HTTP]
+ }
+
export function ids() {
- return ALL.map((t) => t.id)
+ return allTools().map((t) => t.id)
}
export async function tools(providerID: string, _modelID: string) {
const result = await Promise.all(
- ALL.map(async (t) => ({
+ allTools().map(async (t) => ({
id: t.id,
...(await t.init()),
})),
@@ -45,21 +137,21 @@ export namespace ToolRegistry {
if (providerID === "openai") {
return result.map((t) => ({
...t,
- parameters: optionalToNullable(t.parameters),
+ parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
}))
}
if (providerID === "azure") {
return result.map((t) => ({
...t,
- parameters: optionalToNullable(t.parameters),
+ parameters: optionalToNullable(t.parameters as unknown as z.ZodTypeAny),
}))
}
if (providerID === "google") {
return result.map((t) => ({
...t,
- parameters: sanitizeGeminiParameters(t.parameters),
+ parameters: sanitizeGeminiParameters(t.parameters as unknown as z.ZodTypeAny),
}))
}
@@ -68,7 +160,7 @@ export namespace ToolRegistry {
export async function enabled(
_providerID: string,
- modelID: string,
+ _modelID: string,
agent: Agent.Info,
): Promise<Record<string, boolean>> {
const result: Record<string, boolean> = {}
@@ -86,11 +178,6 @@ export namespace ToolRegistry {
result["webfetch"] = false
}
- if (modelID.toLowerCase().includes("qwen")) {
- result["todowrite"] = false
- result["todoread"] = false
- }
-
return result
}
diff --git a/packages/opencode/test/tool/register.test.ts b/packages/opencode/test/tool/register.test.ts
new file mode 100644
index 000000000..2a72c119b
--- /dev/null
+++ b/packages/opencode/test/tool/register.test.ts
@@ -0,0 +1,299 @@
+import "zod-openapi/extend"
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import os from "os"
+import { Instance } from "../../src/project/instance"
+
+// Helper to create a Request targeting the in-memory Hono app
+function makeRequest(method: string, url: string, body?: any) {
+ const headers: Record<string, string> = { "content-type": "application/json" }
+ const init: RequestInit = { method, headers }
+ if (body !== undefined) init.body = JSON.stringify(body)
+ return new Request(url, init)
+}
+
+describe("HTTP tool registration API", () => {
+ test("POST /tool/register then list via /tool/ids and /tool", async () => {
+ const projectRoot = path.join(__dirname, "../..")
+ await Instance.provide(projectRoot, async () => {
+ const { Server } = await import("../../src/server/server")
+
+ const toolSpec = {
+ id: "http-echo",
+ description: "Simple echo tool (test-only)",
+ parameters: {
+ type: "object" as const,
+ properties: {
+ foo: { type: "string" as const, optional: true },
+ bar: { type: "number" as const },
+ },
+ },
+ callbackUrl: "http://localhost:9999/echo",
+ }
+
+ // Register
+ const registerRes = await Server.App.fetch(
+ makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
+ )
+ expect(registerRes.status).toBe(200)
+ const ok = await registerRes.json()
+ expect(ok).toBe(true)
+
+ // IDs should include the new tool
+ const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
+ expect(idsRes.status).toBe(200)
+ const ids = (await idsRes.json()) as string[]
+ expect(ids).toContain("http-echo")
+
+ // List tools for a provider/model and check JSON Schema shape
+ const listRes = await Server.App.fetch(
+ makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
+ )
+ expect(listRes.status).toBe(200)
+ const list = (await listRes.json()) as Array<{ id: string; description: string; parameters: any }>
+ const found = list.find((t) => t.id === "http-echo")
+ expect(found).toBeTruthy()
+ expect(found!.description).toBe("Simple echo tool (test-only)")
+
+ // Basic JSON Schema checks
+ expect(found!.parameters?.type).toBe("object")
+ expect(found!.parameters?.properties?.bar?.type).toBe("number")
+
+ const foo = found!.parameters?.properties?.foo
+ // optional -> nullable for OpenAI/Azure providers; accept either type array including null or nullable: true
+ const fooIsNullable = Array.isArray(foo?.type) ? foo.type.includes("null") : foo?.nullable === true
+ expect(fooIsNullable).toBe(true)
+ })
+ })
+})
+
+describe("Plugin tool.register hook", () => {
+ test("Plugin registers tool during Plugin.init()", async () => {
+ // Create a temporary project directory with opencode.json that points to our plugin
+ const tmpDir = path.join(os.tmpdir(), `opencode-test-project-${Date.now()}`)
+ await Bun.$`mkdir -p ${tmpDir}`
+
+ const tmpPluginPath = path.join(tmpDir, `test-plugin-${Date.now()}.ts`)
+ const pluginCode = `
+ export async function TestPlugin() {
+ return {
+ async ["tool.register"](_input, { registerHTTP }) {
+ registerHTTP({
+ id: "from-plugin",
+ description: "Registered from test plugin",
+ parameters: { type: "object", properties: { name: { type: "string", optional: true } } },
+ callbackUrl: "http://localhost:9999/echo"
+ })
+ }
+ }
+ }
+ `
+ await Bun.write(tmpPluginPath, pluginCode)
+
+ const configPath = path.join(tmpDir, "opencode.json")
+ await Bun.write(configPath, JSON.stringify({ plugin: ["file://" + tmpPluginPath] }, null, 2))
+
+ await Instance.provide(tmpDir, async () => {
+ const { Plugin } = await import("../../src/plugin")
+ const { ToolRegistry } = await import("../../src/tool/registry")
+ const { Server } = await import("../../src/server/server")
+
+ // Initialize plugins (will invoke our tool.register hook)
+ await Plugin.init()
+
+ // Confirm the tool is registered
+ const allIDs = ToolRegistry.ids()
+ expect(allIDs).toContain("from-plugin")
+
+ // Also verify via the HTTP surface
+ const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
+ expect(idsRes.status).toBe(200)
+ const ids = (await idsRes.json()) as string[]
+ expect(ids).toContain("from-plugin")
+ })
+ })
+})
+
+test("Multiple plugins can each register tools", async () => {
+ const tmpDir = path.join(os.tmpdir(), `opencode-test-project-multi-${Date.now()}`)
+ await Bun.$`mkdir -p ${tmpDir}`
+
+ // Create two plugin files
+ const pluginAPath = path.join(tmpDir, `plugin-a-${Date.now()}.ts`)
+ const pluginBPath = path.join(tmpDir, `plugin-b-${Date.now()}.ts`)
+ const pluginA = `
+ export async function PluginA() {
+ return {
+ async ["tool.register"](_input, { registerHTTP }) {
+ registerHTTP({
+ id: "alpha-tool",
+ description: "Alpha tool",
+ parameters: { type: "object", properties: { a: { type: "string", optional: true } } },
+ callbackUrl: "http://localhost:9999/echo"
+ })
+ }
+ }
+ }
+ `
+ const pluginB = `
+ export async function PluginB() {
+ return {
+ async ["tool.register"](_input, { registerHTTP }) {
+ registerHTTP({
+ id: "beta-tool",
+ description: "Beta tool",
+ parameters: { type: "object", properties: { b: { type: "number", optional: true } } },
+ callbackUrl: "http://localhost:9999/echo"
+ })
+ }
+ }
+ }
+ `
+ await Bun.write(pluginAPath, pluginA)
+ await Bun.write(pluginBPath, pluginB)
+
+ // Config with both plugins
+ await Bun.write(
+ path.join(tmpDir, "opencode.json"),
+ JSON.stringify({ plugin: ["file://" + pluginAPath, "file://" + pluginBPath] }, null, 2),
+ )
+
+ await Instance.provide(tmpDir, async () => {
+ const { Plugin } = await import("../../src/plugin")
+ const { ToolRegistry } = await import("../../src/tool/registry")
+ const { Server } = await import("../../src/server/server")
+
+ await Plugin.init()
+
+ const ids = ToolRegistry.ids()
+ expect(ids).toContain("alpha-tool")
+ expect(ids).toContain("beta-tool")
+
+ const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
+ expect(res.status).toBe(200)
+ const httpIds = (await res.json()) as string[]
+ expect(httpIds).toContain("alpha-tool")
+ expect(httpIds).toContain("beta-tool")
+ })
+})
+
+test("Plugin registers native/local tool with function execution", async () => {
+ const tmpDir = path.join(os.tmpdir(), `opencode-test-project-native-${Date.now()}`)
+ await Bun.$`mkdir -p ${tmpDir}`
+
+ const pluginPath = path.join(tmpDir, `plugin-native-${Date.now()}.ts`)
+ const pluginCode = `
+ export async function NativeToolPlugin({ $, Tool, z }) {
+ // Use z (zod) provided by the plugin system
+
+ // Define a native tool using Tool.define from plugin input
+ const MyNativeTool = Tool.define("my-native-tool", {
+ description: "A native tool that runs local code",
+ parameters: z.object({
+ message: z.string().describe("Message to process"),
+ count: z.number().optional().describe("Repeat count").default(1)
+ }),
+ async execute(args, ctx) {
+ // This runs locally in the plugin process, not via HTTP!
+ const result = args.message.repeat(args.count)
+ const output = \`Processed: \${result}\`
+
+ // Can also run shell commands directly
+ const hostname = await $\`hostname\`.text()
+
+ return {
+ title: "Native Tool Result",
+ output: output + " on " + hostname.trim(),
+ metadata: { processedAt: new Date().toISOString() }
+ }
+ }
+ })
+
+ return {
+ async ["tool.register"](_input, { register, registerHTTP }) {
+ // Register our native tool
+ register(MyNativeTool)
+
+ // Can also register HTTP tools in the same plugin
+ registerHTTP({
+ id: "http-tool-from-same-plugin",
+ description: "HTTP tool alongside native tool",
+ parameters: { type: "object", properties: {} },
+ callbackUrl: "http://localhost:9999/echo"
+ })
+ }
+ }
+ }
+ `
+ await Bun.write(pluginPath, pluginCode)
+
+ await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
+
+ await Instance.provide(tmpDir, async () => {
+ const { Plugin } = await import("../../src/plugin")
+ const { ToolRegistry } = await import("../../src/tool/registry")
+ const { Server } = await import("../../src/server/server")
+
+ await Plugin.init()
+
+ // Both tools should be registered
+ const ids = ToolRegistry.ids()
+ expect(ids).toContain("my-native-tool")
+ expect(ids).toContain("http-tool-from-same-plugin")
+
+ // Verify via HTTP endpoint
+ const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
+ expect(res.status).toBe(200)
+ const httpIds = (await res.json()) as string[]
+ expect(httpIds).toContain("my-native-tool")
+ expect(httpIds).toContain("http-tool-from-same-plugin")
+
+ // Get tool details to verify native tool has proper structure
+ const toolsRes = await Server.App.fetch(
+ new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
+ )
+ expect(toolsRes.status).toBe(200)
+ const tools = (await toolsRes.json()) as any[]
+ const nativeTool = tools.find((t) => t.id === "my-native-tool")
+ expect(nativeTool).toBeTruthy()
+ expect(nativeTool.description).toBe("A native tool that runs local code")
+ expect(nativeTool.parameters.properties.message).toBeTruthy()
+ expect(nativeTool.parameters.properties.count).toBeTruthy()
+ })
+})
+
+// Malformed plugin (no tool.register) should not throw and should not register anything
+test("Plugin without tool.register is handled gracefully", async () => {
+ const tmpDir = path.join(os.tmpdir(), `opencode-test-project-noreg-${Date.now()}`)
+ await Bun.$`mkdir -p ${tmpDir}`
+
+ const pluginPath = path.join(tmpDir, `plugin-noreg-${Date.now()}.ts`)
+ const pluginSrc = `
+ export async function NoRegisterPlugin() {
+ return {
+ // no tool.register hook provided
+ async config(_cfg) { /* noop */ }
+ }
+ }
+ `
+ await Bun.write(pluginPath, pluginSrc)
+
+ await Bun.write(path.join(tmpDir, "opencode.json"), JSON.stringify({ plugin: ["file://" + pluginPath] }, null, 2))
+
+ await Instance.provide(tmpDir, async () => {
+ const { Plugin } = await import("../../src/plugin")
+ const { ToolRegistry } = await import("../../src/tool/registry")
+ const { Server } = await import("../../src/server/server")
+
+ await Plugin.init()
+
+ // Ensure our specific id isn't present
+ const ids = ToolRegistry.ids()
+ expect(ids).not.toContain("malformed-tool")
+
+ const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
+ expect(res.status).toBe(200)
+ const httpIds = (await res.json()) as string[]
+ expect(httpIds).not.toContain("malformed-tool")
+ })
+})
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index b8da5a17a..789216dee 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
- "version": "0.6.4",
+ "version": "0.6.6",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts
index a00b48d10..fca176f57 100644
--- a/packages/plugin/src/index.ts
+++ b/packages/plugin/src/index.ts
@@ -18,9 +18,34 @@ export type PluginInput = {
directory: string
worktree: string
$: BunShell
+ Tool: {
+ define(
+ id: string,
+ init: any | (() => Promise<any>)
+ ): any
+ }
+ z: any // Zod instance for creating schemas
}
export type Plugin = (input: PluginInput) => Promise<Hooks>
+// Lightweight schema spec for HTTP-registered tools
+export type HttpParamSpec = {
+ type: "string" | "number" | "boolean" | "array"
+ description?: string
+ optional?: boolean
+ items?: "string" | "number" | "boolean"
+}
+export type HttpToolRegistration = {
+ id: string
+ description: string
+ parameters: {
+ type: "object"
+ properties: Record<string, HttpParamSpec>
+ }
+ callbackUrl: string
+ headers?: Record<string, string>
+}
+
export interface Hooks {
event?: (input: { event: Event }) => Promise<void>
config?: (input: Config) => Promise<void>
@@ -99,4 +124,16 @@ export interface Hooks {
metadata: any
},
) => Promise<void>
+ /**
+ * Allow plugins to register additional tools with the server.
+ * Use registerHTTP to add a tool that calls back to your plugin/service.
+ * Use register to add a native/local tool with direct function execution.
+ */
+ "tool.register"?: (
+ input: {},
+ output: {
+ registerHTTP: (tool: HttpToolRegistration) => void | Promise<void>
+ register: (tool: any) => void | Promise<void> // Tool.Info type from opencode
+ },
+ ) => Promise<void>
}
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index af9021e14..8d42d0c75 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
- "version": "0.6.4",
+ "version": "0.6.6",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index ac6be13d5..ea9cfe9a1 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -10,6 +10,15 @@ import type {
EventSubscribeResponses,
ConfigGetData,
ConfigGetResponses,
+ ToolRegisterData,
+ ToolRegisterResponses,
+ ToolRegisterErrors,
+ ToolIdsData,
+ ToolIdsResponses,
+ ToolIdsErrors,
+ ToolListData,
+ ToolListResponses,
+ ToolListErrors,
PathGetData,
PathGetResponses,
SessionListData,
@@ -178,6 +187,42 @@ class Config extends _HeyApiClient {
}
}
+class Tool extends _HeyApiClient {
+ /**
+ * Register a new HTTP callback tool
+ */
+ public register<ThrowOnError extends boolean = false>(options?: Options<ToolRegisterData, ThrowOnError>) {
+ return (options?.client ?? this._client).post<ToolRegisterResponses, ToolRegisterErrors, ThrowOnError>({
+ url: "/experimental/tool/register",
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ },
+ })
+ }
+
+ /**
+ * List all tool IDs (including built-in and dynamically registered)
+ */
+ public ids<ThrowOnError extends boolean = false>(options?: Options<ToolIdsData, ThrowOnError>) {
+ return (options?.client ?? this._client).get<ToolIdsResponses, ToolIdsErrors, ThrowOnError>({
+ url: "/experimental/tool/ids",
+ ...options,
+ })
+ }
+
+ /**
+ * List tools with JSON schema parameters for a provider/model
+ */
+ public list<ThrowOnError extends boolean = false>(options: Options<ToolListData, ThrowOnError>) {
+ return (options.client ?? this._client).get<ToolListResponses, ToolListErrors, ThrowOnError>({
+ url: "/experimental/tool",
+ ...options,
+ })
+ }
+}
+
class Path extends _HeyApiClient {
/**
* Get the current path
@@ -649,6 +694,7 @@ export class OpencodeClient extends _HeyApiClient {
project = new Project({ client: this._client })
event = new Event({ client: this._client })
config = new Config({ client: this._client })
+ tool = new Tool({ client: this._client })
path = new Path({ client: this._client })
session = new Session({ client: this._client })
command = new Command({ client: this._client })
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 8befce1ce..2c0376ef2 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1053,6 +1053,44 @@ export type McpRemoteConfig = {
export type LayoutConfig = "auto" | "stretch"
+export type _Error = {
+ data: {
+ [key: string]: unknown
+ }
+}
+
+export type HttpToolRegistration = {
+ id: string
+ description: string
+ parameters: {
+ type: "object"
+ properties: {
+ [key: string]: HttpParamSpec
+ }
+ }
+ callbackUrl: string
+ headers?: {
+ [key: string]: string
+ }
+}
+
+export type HttpParamSpec = {
+ type: "string" | "number" | "boolean" | "array"
+ description?: string
+ optional?: boolean
+ items?: "string" | "number" | "boolean"
+}
+
+export type ToolIds = Array<string>
+
+export type ToolList = Array<ToolListItem>
+
+export type ToolListItem = {
+ id: string
+ description: string
+ parameters?: unknown
+}
+
export type Path = {
state: string
config: string
@@ -1060,12 +1098,6 @@ export type Path = {
directory: string
}
-export type _Error = {
- data: {
- [key: string]: unknown
- }
-}
-
export type TextPartInput = {
id?: string
type: "text"
@@ -1276,6 +1308,89 @@ export type ConfigGetResponses = {
export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]
+export type ToolRegisterData = {
+ body?: HttpToolRegistration
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/experimental/tool/register"
+}
+
+export type ToolRegisterErrors = {
+ /**
+ * Bad request
+ */
+ 400: _Error
+}
+
+export type ToolRegisterError = ToolRegisterErrors[keyof ToolRegisterErrors]
+
+export type ToolRegisterResponses = {
+ /**
+ * Tool registered successfully
+ */
+ 200: boolean
+}
+
+export type ToolRegisterResponse = ToolRegisterResponses[keyof ToolRegisterResponses]
+
+export type ToolIdsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/experimental/tool/ids"
+}
+
+export type ToolIdsErrors = {
+ /**
+ * Bad request
+ */
+ 400: _Error
+}
+
+export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]
+
+export type ToolIdsResponses = {
+ /**
+ * Tool IDs
+ */
+ 200: ToolIds
+}
+
+export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]
+
+export type ToolListData = {
+ body?: never
+ path?: never
+ query: {
+ directory?: string
+ provider: string
+ model: string
+ }
+ url: "/experimental/tool"
+}
+
+export type ToolListErrors = {
+ /**
+ * Bad request
+ */
+ 400: _Error
+}
+
+export type ToolListError = ToolListErrors[keyof ToolListErrors]
+
+export type ToolListResponses = {
+ /**
+ * Tools
+ */
+ 200: ToolList
+}
+
+export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
+
export type PathGetData = {
body?: never
path?: never
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index e43d297b8..907734a12 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -659,6 +659,9 @@ func (m *editorComponent) getExitKeyText() string {
// shouldSummarizePastedText determines if pasted text should be summarized
func (m *editorComponent) shouldSummarizePastedText(text string) bool {
+ if m.app.IsBashMode {
+ return false
+ }
lines := strings.Split(text, "\n")
lineCount := len(lines)
charCount := len(text)
diff --git a/packages/web/package.json b/packages/web/package.json
index 8419bcb7f..c55d4ce50 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
- "version": "0.6.4",
+ "version": "0.6.6",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 00b8c9337..3d161b87d 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
- "version": "0.6.4",
+ "version": "0.6.6",
"publisher": "sst-dev",
"repository": {
"type": "git",
diff --git a/sst-env.d.ts b/sst-env.d.ts
index 534b20a69..c1999089e 100644
--- a/sst-env.d.ts
+++ b/sst-env.d.ts
@@ -64,6 +64,13 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "HONEYCOMB_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "LogProcessor": {
+ "type": "sst.cloudflare.Worker"
+ }
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string