From ca8c23dd717e36880d736a1df726880d38dd4366 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:24:56 -0600 Subject: [PATCH 01/47] ci: add license to npm (#11883) --- packages/opencode/script/publish.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 5fc4f3c711f..3113a85003c 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -18,6 +18,7 @@ const version = Object.values(binaries)[0] await $`mkdir -p ./dist/${pkg.name}` await $`cp -r ./bin ./dist/${pkg.name}/bin` await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` +await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) await Bun.file(`./dist/${pkg.name}/package.json`).write( JSON.stringify( @@ -30,6 +31,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write( postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs", }, version: version, + license: pkg.license, optionalDependencies: binaries, }, null, From 1275c71a63c2ef34f46734b0802dd34435fcb5f6 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 2 Feb 2026 23:35:23 -0500 Subject: [PATCH 02/47] cli: make run non-interactive (#11814) --- .opencode/opencode.jsonc | 7 +- packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/run.ts | 546 +++++++++++++++++-------- packages/opencode/src/tool/registry.ts | 2 +- 4 files changed, 368 insertions(+), 188 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index c3f0b7070d1..52fd004324a 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -9,12 +9,7 @@ "options": {}, }, }, - "mcp": { - "context7": { - "type": "remote", - "url": "https://mcp.context7.com/mcp", - }, - }, + "mcp": {}, "tools": { "github-triage": false, "github-pr-search": false, diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a993494a51e..08eaae49de0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -20,6 +20,7 @@ "bin": { "opencode": "./bin/opencode" }, + "randomField": "this-is-a-random-value-12345", "exports": { "./*": "./src/*.ts" }, diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 54248f96f3d..59d0e01a7cc 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -4,25 +4,211 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" -import { Command } from "../../command" import { EOL } from "os" -import { select } from "@clack/prompts" -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" +import { PermissionNext } from "../../permission/next" +import { Tool } from "../../tool/tool" +import { GlobTool } from "../../tool/glob" +import { GrepTool } from "../../tool/grep" +import { ListTool } from "../../tool/ls" +import { ReadTool } from "../../tool/read" +import { WebFetchTool } from "../../tool/webfetch" +import { EditTool } from "../../tool/edit" +import { WriteTool } from "../../tool/write" +import { CodeSearchTool } from "../../tool/codesearch" +import { WebSearchTool } from "../../tool/websearch" +import { TaskTool } from "../../tool/task" +import { SkillTool } from "../../tool/skill" +import { BashTool } from "../../tool/bash" +import { TodoWriteTool } from "../../tool/todo" +import { Locale } from "../../util/locale" -const TOOL: Record = { - todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], - edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], - glob: ["Glob", UI.Style.TEXT_INFO_BOLD], - grep: ["Grep", UI.Style.TEXT_INFO_BOLD], - list: ["List", UI.Style.TEXT_INFO_BOLD], - read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], - write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], - websearch: ["Search", UI.Style.TEXT_DIM_BOLD], +type ToolProps = { + input: Tool.InferParameters + metadata: Tool.InferMetadata + part: ToolPart +} + +function props(part: ToolPart): ToolProps { + const state = part.state + return { + input: state.input as Tool.InferParameters, + metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, + part, + } +} + +type Inline = { + icon: string + title: string + description?: string +} + +function inline(info: Inline) { + const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" + UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) +} + +function block(info: Inline, output?: string) { + UI.empty() + inline(info) + if (!output?.trim()) return + UI.println(output) + UI.empty() +} + +function fallback(part: ToolPart) { + const state = part.state + const input = "input" in state ? state.input : undefined + const title = + ("title" in state && state.title ? state.title : undefined) || + (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") + inline({ + icon: "⚙", + title: `${part.tool} ${title}`, + }) +} + +function glob(info: ToolProps) { + const root = info.input.path ?? "" + const title = `Glob "${info.input.pattern}"` + const suffix = root ? `in ${normalizePath(root)}` : "" + const num = info.metadata.count + const description = + num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` + inline({ + icon: "✱", + title, + ...(description && { description }), + }) +} + +function grep(info: ToolProps) { + const root = info.input.path ?? "" + const title = `Grep "${info.input.pattern}"` + const suffix = root ? `in ${normalizePath(root)}` : "" + const num = info.metadata.matches + const description = + num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}` + inline({ + icon: "✱", + title, + ...(description && { description }), + }) +} + +function list(info: ToolProps) { + const dir = info.input.path ? normalizePath(info.input.path) : "" + inline({ + icon: "→", + title: dir ? `List ${dir}` : "List", + }) +} + +function read(info: ToolProps) { + const file = normalizePath(info.input.filePath) + const pairs = Object.entries(info.input).filter(([key, value]) => { + if (key === "filePath") return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined + inline({ + icon: "→", + title: `Read ${file}`, + ...(description && { description }), + }) +} + +function write(info: ToolProps) { + block( + { + icon: "←", + title: `Write ${normalizePath(info.input.filePath)}`, + }, + info.part.state.status === "completed" ? info.part.state.output : undefined, + ) +} + +function webfetch(info: ToolProps) { + inline({ + icon: "%", + title: `WebFetch ${info.input.url}`, + }) +} + +function edit(info: ToolProps) { + const title = normalizePath(info.input.filePath) + const diff = info.metadata.diff + block( + { + icon: "←", + title: `Edit ${title}`, + }, + diff, + ) +} + +function codesearch(info: ToolProps) { + inline({ + icon: "◇", + title: `Exa Code Search "${info.input.query}"`, + }) +} + +function websearch(info: ToolProps) { + inline({ + icon: "◈", + title: `Exa Web Search "${info.input.query}"`, + }) +} + +function task(info: ToolProps) { + const agent = Locale.titlecase(info.input.subagent_type) + const desc = info.input.description + const started = info.part.state.status === "running" + const name = desc ?? `${agent} Task` + inline({ + icon: started ? "•" : "✓", + title: name, + description: desc ? `${agent} Agent` : undefined, + }) +} + +function skill(info: ToolProps) { + inline({ + icon: "→", + title: `Skill "${info.input.name}"`, + }) +} + +function bash(info: ToolProps) { + const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined + block( + { + icon: "$", + title: `${info.input.command}`, + }, + output, + ) +} + +function todo(info: ToolProps) { + block( + { + icon: "#", + title: "Todos", + }, + info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), + ) +} + +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." + return input } export const RunCommand = cmd({ @@ -97,11 +283,11 @@ export const RunCommand = cmd({ .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - const fileParts: any[] = [] + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] if (args.file) { - const files = Array.isArray(args.file) ? args.file : [args.file] + const list = Array.isArray(args.file) ? args.file : [args.file] - for (const filePath of files) { + for (const filePath of list) { const resolvedPath = path.resolve(process.cwd(), filePath) const file = Bun.file(resolvedPath) const stats = await file.stat().catch(() => {}) @@ -117,7 +303,7 @@ export const RunCommand = cmd({ const stat = await file.stat() const mime = stat.isDirectory() ? "application/x-directory" : "text/plain" - fileParts.push({ + files.push({ type: "file", url: `file://${resolvedPath}`, filename: path.basename(resolvedPath), @@ -133,17 +319,75 @@ export const RunCommand = cmd({ process.exit(1) } - const execute = async (sdk: OpencodeClient, sessionID: string) => { - const printEvent = (color: string, type: string, title: string) => { - UI.println( - color + `|`, - UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, - "", - UI.Style.TEXT_NORMAL + title, - ) + const rules: PermissionNext.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + if (args.continue) { + const result = await sdk.session.list() + return result.data?.find((s) => !s.parentID)?.id + } + if (args.session) return args.session + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } + + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + } + } + + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + if (part.tool === "bash") return bash(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "list") return list(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "codesearch") return codesearch(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) } - const outputJsonEvent = (type: string, data: any) => { + function emit(type: string, data: Record) { if (args.format === "json") { process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) return true @@ -152,41 +396,77 @@ export const RunCommand = cmd({ } const events = await sdk.event.subscribe() - let errorMsg: string | undefined + let error: string | undefined + + async function loop() { + const toggles = new Map() - const eventProcessor = (async () => { for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID !== sessionID) continue if (part.type === "tool" && part.state.status === "completed") { - if (outputJsonEvent("tool_use", { part })) continue - const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] - const title = - part.state.title || - (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") - printEvent(color, tool, title) - if (part.tool === "bash" && part.state.output?.trim()) { - UI.println() - UI.println(part.state.output) - } + if (emit("tool_use", { part })) continue + tool(part) + } + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) } if (part.type === "step-start") { - if (outputJsonEvent("step_start", { part })) continue + if (emit("step_start", { part })) continue } if (part.type === "step-finish") { - if (outputJsonEvent("step_finish", { part })) continue + if (emit("step_finish", { part })) continue } if (part.type === "text" && part.time?.end) { - if (outputJsonEvent("text", { part })) continue - const isPiped = !process.stdout.isTTY - if (!isPiped) UI.println() - process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) - if (!isPiped) UI.println() + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) } } @@ -197,42 +477,40 @@ export const RunCommand = cmd({ if ("data" in props.error && props.error.data && "message" in props.error.data) { err = String(props.error.data.message) } - errorMsg = errorMsg ? errorMsg + EOL + err : err - if (outputJsonEvent("error", { error: props.error })) continue + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue UI.error(err) } - if (event.type === "session.idle" && event.properties.sessionID === sessionID) { + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { break } if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue - const result = await select({ - message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, - options: [ - { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow: " + permission.always.join(", ") }, - { value: "reject", label: "Reject" }, - ], - initialValue: "once", - }).catch(() => "reject") - const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject" - await sdk.permission.respond({ - sessionID, - permissionID: permission.id, - response, + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", }) } } - })() + } // Validate agent if specified - const resolvedAgent = await (async () => { + const agent = await (async () => { if (!args.agent) return undefined - const agent = await Agent.get(args.agent) - if (!agent) { + const entry = await Agent.get(args.agent) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -240,7 +518,7 @@ export const RunCommand = cmd({ ) return undefined } - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -251,91 +529,42 @@ export const RunCommand = cmd({ return args.agent })() + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) + } + await share(sdk, sessionID) + + loop().catch((e) => { + console.error(e) + process.exit(1) + }) + if (args.command) { await sdk.session.command({ sessionID, - agent: resolvedAgent, + agent, model: args.model, command: args.command, arguments: message, variant: args.variant, }) } else { - const modelParam = args.model ? Provider.parseModel(args.model) : undefined + const model = args.model ? Provider.parseModel(args.model) : undefined await sdk.session.prompt({ sessionID, - agent: resolvedAgent, - model: modelParam, + agent, + model, variant: args.variant, - parts: [...fileParts, { type: "text", text: message }], + parts: [...files, { type: "text", text: message }], }) } - - await eventProcessor - if (errorMsg) process.exit(1) } if (args.attach) { const sdk = createOpencodeClient({ baseUrl: args.attach }) - - const sessionID = await (async () => { - if (args.continue) { - const result = await sdk.session.list() - return result.data?.find((s) => !s.parentID)?.id - } - if (args.session) return args.session - - const title = - args.title !== undefined - ? args.title === "" - ? message.slice(0, 50) + (message.length > 50 ? "..." : "") - : args.title - : undefined - - const result = await sdk.session.create( - title - ? { - title, - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - } - : { - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }, - ) - return result.data?.id - })() - - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - - const cfgResult = await sdk.config.get() - if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { - const shareResult = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) - } - } - - return await execute(sdk, sessionID) + return await execute(sdk) } await bootstrap(process.cwd(), async () => { @@ -344,52 +573,7 @@ export const RunCommand = cmd({ return Server.App().fetch(request) }) as typeof globalThis.fetch const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - - if (args.command) { - const exists = await Command.get(args.command) - if (!exists) { - UI.error(`Command "${args.command}" not found`) - process.exit(1) - } - } - - const sessionID = await (async () => { - if (args.continue) { - const result = await sdk.session.list() - return result.data?.find((s) => !s.parentID)?.id - } - if (args.session) return args.session - - const title = - args.title !== undefined - ? args.title === "" - ? message.slice(0, 50) + (message.length > 50 ? "..." : "") - : args.title - : undefined - - const result = await sdk.session.create(title ? { title } : {}) - return result.data?.id - })() - - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - - const cfgResult = await sdk.config.get() - if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { - const shareResult = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) - } - } - - await execute(sdk, sessionID) + await execute(sdk) }) }, }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7b3a4588972..3cb7715947b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -110,7 +110,7 @@ export namespace ToolRegistry { TaskTool, WebFetchTool, TodoWriteTool, - TodoReadTool, + // TodoReadTool, WebSearchTool, CodeSearchTool, SkillTool, From 3adeed8f97d612171dfdecf8cc33eee193811172 Mon Sep 17 00:00:00 2001 From: Kiyoung Chang <81131079+ChickenBreast-ky@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:13:24 +0900 Subject: [PATCH 03/47] fix(provider): strip properties/required from non-object types in Gemini schema (#11888) --- packages/opencode/src/provider/transform.ts | 6 + .../opencode/test/provider/transform.test.ts | 110 ++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c05bf75c46f..b4f1aaca4d5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -772,6 +772,12 @@ export namespace ProviderTransform { result.items = {} } + // Remove properties/required from non-object types (Gemini rejects these) + if (result.type && result.type !== "object") { + delete result.properties + delete result.required + } + return result } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 8e28f1209e1..0743049fe06 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -293,6 +293,116 @@ describe("ProviderTransform.schema - gemini array items", () => { }) }) +describe("ProviderTransform.schema - gemini non-object properties removal", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("removes properties from non-object types", () => { + const schema = { + type: "object", + properties: { + data: { + type: "string", + properties: { invalid: { type: "string" } }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.type).toBe("string") + expect(result.properties.data.properties).toBeUndefined() + }) + + test("removes required from non-object types", () => { + const schema = { + type: "object", + properties: { + data: { + type: "array", + items: { type: "string" }, + required: ["invalid"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.type).toBe("array") + expect(result.properties.data.required).toBeUndefined() + }) + + test("removes properties and required from nested non-object types", () => { + const schema = { + type: "object", + properties: { + outer: { + type: "object", + properties: { + inner: { + type: "number", + properties: { bad: { type: "string" } }, + required: ["bad"], + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.outer.properties.inner.type).toBe("number") + expect(result.properties.outer.properties.inner.properties).toBeUndefined() + expect(result.properties.outer.properties.inner.required).toBeUndefined() + }) + + test("keeps properties and required on object types", () => { + const schema = { + type: "object", + properties: { + data: { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.type).toBe("object") + expect(result.properties.data.properties).toBeDefined() + expect(result.properties.data.required).toEqual(["name"]) + }) + + test("does not affect non-gemini providers", () => { + const openaiModel = { + providerID: "openai", + api: { + id: "gpt-4", + }, + } as any + + const schema = { + type: "object", + properties: { + data: { + type: "string", + properties: { invalid: { type: "string" } }, + }, + }, + } as any + + const result = ProviderTransform.schema(openaiModel, schema) as any + + expect(result.properties.data.properties).toBeDefined() + }) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ From 801e4a8a9dbf2e11859e1e4126b7f1d058722eee Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 3 Feb 2026 00:17:03 -0500 Subject: [PATCH 04/47] wip: zen --- packages/console/app/src/routes/zen/util/handler.ts | 2 +- packages/console/app/src/routes/zen/util/rateLimiter.ts | 8 ++++++-- packages/console/core/src/model.ts | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index b97af851e2b..91fa306af45 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -79,7 +79,7 @@ export async function handler( const dataDumper = createDataDumper(sessionId, requestId, projectId) const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient) const isTrial = await trialLimiter?.isTrial() - const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip) + const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers) await rateLimiter?.check() const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId) const stickyProvider = await stickyTracker?.get() diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index d54bd0306d9..918c33a4036 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -4,9 +4,13 @@ import { RateLimitError } from "./error" import { logger } from "./logger" import { ZenData } from "@opencode-ai/console-core/model.js" -export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) { +export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, headers: Headers) { if (!limit) return + const limitValue = (limit.checkHeader && !headers.get(limit.checkHeader)) + ? limit.fallbackValue! + : limit.value + const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() const intervals = @@ -32,7 +36,7 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s ) const total = rows.reduce((sum, r) => sum + r.count, 0) logger.debug(`rate limit total: ${total}`) - if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) + if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) }, } } diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index fc9674cedbc..6b06f275d4d 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -21,6 +21,8 @@ export namespace ZenData { const RateLimitSchema = z.object({ period: z.enum(["day", "rolling"]), value: z.number().int(), + checkHeader: z.string().optional(), + fallbackValue: z.number().int().optional(), }) export type Format = z.infer export type Trial = z.infer From 3f07dffbb0bbf591755321498037f003fc483444 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 3 Feb 2026 05:19:12 +0000 Subject: [PATCH 05/47] chore: generate --- packages/console/app/src/routes/zen/util/rateLimiter.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index 918c33a4036..90e10479c44 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -7,9 +7,7 @@ import { ZenData } from "@opencode-ai/console-core/model.js" export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, headers: Headers) { if (!limit) return - const limitValue = (limit.checkHeader && !headers.get(limit.checkHeader)) - ? limit.fallbackValue! - : limit.value + const limitValue = limit.checkHeader && !headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() From d116c227e0dcf614b033895b707728cf330cac89 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:47:52 +0100 Subject: [PATCH 06/47] fix(core): plugins are always reinstalled (#9675) --- packages/opencode/src/bun/index.ts | 13 ++++- packages/opencode/src/bun/registry.ts | 48 +++++++++++++++++++ packages/opencode/src/config/config.ts | 46 ++++++++++++++---- .../opencode/test/mcp/oauth-browser.test.ts | 42 ++++++---------- 4 files changed, 112 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/src/bun/registry.ts diff --git a/packages/opencode/src/bun/index.ts b/packages/opencode/src/bun/index.ts index 8d061aed56f..19edb6eec4b 100644 --- a/packages/opencode/src/bun/index.ts +++ b/packages/opencode/src/bun/index.ts @@ -6,6 +6,7 @@ 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" export namespace BunProc { const log = Log.create({ service: "bun" }) @@ -73,7 +74,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] + + 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 }) + } const proxied = !!( process.env.HTTP_PROXY || diff --git a/packages/opencode/src/bun/registry.ts b/packages/opencode/src/bun/registry.ts new file mode 100644 index 00000000000..c567668acd7 --- /dev/null +++ b/packages/opencode/src/bun/registry.ts @@ -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 { + 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 { + 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 + } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 54ca94ae4d3..dae6db6f969 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -28,6 +28,7 @@ import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" +import { PackageRegistry } from "@/bun/registry" export namespace Config { const log = Log.create({ service: "config" }) @@ -154,9 +155,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 +237,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, "{}") @@ -244,18 +247,43 @@ export namespace Config { const hasGitIgnore = await Bun.file(gitignore).exists() 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"], - { - cwd: dir, - }, - ).catch(() => {}) + await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], { + cwd: dir, + }).catch(() => {}) // 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(() => {}) } + async function needsInstall(dir: string) { + 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[]) { for (const pattern of patterns) { const index = item.indexOf(pattern) diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 598a0315ef8..ee4429be757 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -8,6 +8,7 @@ let openCalledWith: string | undefined mock.module("open", () => ({ default: async (url: string) => { openCalledWith = url + // Return a mock subprocess that emits an error if openShouldFail is true const subprocess = new EventEmitter() if (openShouldFail) { @@ -133,20 +134,17 @@ test("BrowserOpenFailed event is published when open() throws", async () => { }) // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server") + // Attach a handler immediately so callback shutdown rejections + // don't show up as unhandled between tests. + const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined) - // Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published) - await new Promise((resolve) => setTimeout(resolve, 200)) + // Config.get() can be slow in tests, so give it plenty of time. + await new Promise((resolve) => setTimeout(resolve, 2_000)) // Stop the callback server and cancel any pending auth await McpOAuthCallback.stop() - // Wait for authenticate to reject (due to server stopping) - try { - await authPromise - } catch { - // Expected to fail - } + await authPromise unsubscribe() @@ -187,20 +185,15 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }) // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server-2") + const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined) - // Wait for the browser open attempt and the 500ms error detection timeout - await new Promise((resolve) => setTimeout(resolve, 700)) + // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. + await new Promise((resolve) => setTimeout(resolve, 2_000)) // Stop the callback server and cancel any pending auth await McpOAuthCallback.stop() - // Wait for authenticate to reject (due to server stopping) - try { - await authPromise - } catch { - // Expected to fail - } + await authPromise unsubscribe() @@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => { openCalledWith = undefined // Run authenticate with a timeout to avoid waiting forever for the callback - const authPromise = MCP.authenticate("test-oauth-server-3") + const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined) - // Wait for the browser open attempt and the 500ms error detection timeout - await new Promise((resolve) => setTimeout(resolve, 700)) + // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window. + await new Promise((resolve) => setTimeout(resolve, 2_000)) // Stop the callback server and cancel any pending auth await McpOAuthCallback.stop() - // Wait for authenticate to reject (due to server stopping) - try { - await authPromise - } catch { - // Expected to fail - } + await authPromise // Verify open was called with a URL expect(openCalledWith).toBeDefined() From 0d22068c90430e217c9c903a003b22668890207c Mon Sep 17 00:00:00 2001 From: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:17:13 -0600 Subject: [PATCH 07/47] fix(app): custom providers overflow (#11252) Co-authored-by: Brendan Allan --- packages/app/src/components/settings-providers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index dcc597139e3..d2444e2d2a9 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -226,11 +226,11 @@ export const SettingsProviders: Component = () => {
-
+
Custom provider {language.t("settings.providers.tag.custom")} From e709808b3214afcf5ab8582829d1339a9eac79c0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 05:19:14 -0600 Subject: [PATCH 08/47] fix(app): move session search to command palette --- .../app/src/components/dialog-select-file.tsx | 186 +++++++++++++-- packages/app/src/pages/layout.tsx | 212 +----------------- 2 files changed, 170 insertions(+), 228 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 64b83d31bf0..167f211953a 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,17 +1,22 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" +import { base64Encode } from "@opencode-ai/util/encode" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useParams } from "@solidjs/router" -import { createMemo, createSignal, onCleanup, Show } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js" import { formatKeybind, useCommand, type CommandOption } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" -type EntryType = "command" | "file" +type EntryType = "command" | "file" | "session" type Entry = { id: string @@ -22,6 +27,9 @@ type Entry = { category: string option?: CommandOption path?: string + directory?: string + sessionID?: string + archived?: number } type DialogSelectFileMode = "all" | "files" @@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const file = useFile() const dialog = useDialog() const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) @@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil path, }) + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const sessionItem = (input: { + directory: string + id: string + title: string + description: string + archived?: number + }): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category: language.t("command.category.session"), + directory: input.directory, + sessionID: input.id, + archived: input.archived, + }) + const list = createMemo(() => allowed().map(commandItem)) const picks = createMemo(() => { @@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return out } + const sessionToken = { value: 0 } + let sessionInflight: Promise | undefined + let sessionAll: Entry[] | undefined + + const sessions = (text: string) => { + const query = text.trim() + if (!query) { + sessionToken.value += 1 + sessionInflight = undefined + sessionAll = undefined + return [] as Entry[] + } + + if (sessionAll) return sessionAll + if (sessionInflight) return sessionInflight + + const current = sessionToken.value + const dirs = workspaces() + if (dirs.length === 0) return [] as Entry[] + + sessionInflight = Promise.all( + dirs.map((directory) => { + const description = label(directory) + return globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? language.t("command.session.new"), + description, + directory, + archived: s.time?.archived, + })), + ) + .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) + }), + ) + .then((results) => { + if (sessionToken.value !== current) return [] as Entry[] + const seen = new Set() + const next = results + .flat() + .filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map(sessionItem) + sessionAll = next + return next + }) + .catch(() => [] as Entry[]) + .finally(() => { + sessionInflight = undefined + }) + + return sessionInflight + } + const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) @@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil const files = await file.searchFiles(query) return files.map(fileItem) } - const files = await file.searchFiles(query) + + const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) const entries = files.map(fileItem) - return [...list(), ...entries] + return [...list(), ...nextSessions, ...entries] } const handleMove = (item: Entry | undefined) => { @@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil return } + if (item.type === "session") { + if (!item.directory || !item.sessionID) return + navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`) + return + } + if (!item.path) return open(item.path) } @@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil items={items} key={(item) => item.id} filterKeys={["title", "description", "category"]} - groupBy={(item) => item.category} + groupBy={grouped() ? (item) => item.category : () => ""} onMove={handleMove} onSelect={handleSelect} > {(item) => ( -
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
} > -
-
- {item.title} - - {item.description} + +
+
+ {item.title} + + {item.description} + +
+ + {formatKeybind(item.keybind ?? "")}
- - {formatKeybind(item.keybind ?? "")} - -
- + + +
+
+ +
+ + {item.title} + + + + {item.description} + + +
+
+
+
+ )} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2f963ae28d8..46c9c9154ff 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,7 +27,6 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" -import { List, type ListRef } from "@opencode-ai/ui/list" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -2706,14 +2705,6 @@ export default function Layout(props: ParentProps) { } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { - type SearchItem = { - id: string - title: string - directory: string - label: string - archived?: number - } - const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -2729,107 +2720,6 @@ export default function Layout(props: ParentProps) { }) const homedir = createMemo(() => globalSync.data.path.home) - const [search, setSearch] = createStore({ - value: "", - }) - const searching = createMemo(() => search.value.trim().length > 0) - let searchRef: HTMLInputElement | undefined - let listRef: ListRef | undefined - - const token = { value: 0 } - let inflight: Promise | undefined - let all: SearchItem[] | undefined - - const reset = () => { - token.value += 1 - inflight = undefined - all = undefined - setSearch({ value: "" }) - listRef = undefined - } - - const open = (item: SearchItem | undefined) => { - if (!item) return - - const href = `/${base64Encode(item.directory)}/session/${item.id}` - if (!layout.sidebar.opened()) { - setState("hoverSession", undefined) - setState("hoverProject", undefined) - } - reset() - navigate(href) - layout.mobileSidebar.hide() - } - - const items = (filter: string) => { - const query = filter.trim() - if (!query) { - token.value += 1 - inflight = undefined - all = undefined - return [] as SearchItem[] - } - - const project = panelProps.project - if (!project) return [] as SearchItem[] - if (all) return all - if (inflight) return inflight - - const current = token.value - const dirs = workspaceIds(project) - inflight = Promise.all( - dirs.map((input) => { - const directory = workspaceKey(input) - const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) - const kind = - directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) - const label = `${kind} : ${name}` - return globalSDK.client.session - .list({ directory, roots: true }) - .then((x) => - (x.data ?? []) - .filter((s) => !!s?.id) - .map((s) => ({ - id: s.id, - title: s.title ?? language.t("command.session.new"), - directory, - label, - archived: s.time?.archived, - })), - ) - .catch(() => [] as SearchItem[]) - }), - ) - .then((results) => { - if (token.value !== current) return [] as SearchItem[] - - const seen = new Set() - const next = results.flat().filter((item) => { - const key = `${item.directory}:${item.id}` - if (seen.has(key)) return false - seen.add(key) - return true - }) - all = next - return next - }) - .catch(() => [] as SearchItem[]) - .finally(() => { - inflight = undefined - }) - - return inflight - } - - createEffect( - on( - () => panelProps.project?.worktree, - () => reset(), - { defer: true }, - ), - ) - return (
-
-
{ - const target = event.target - if (!(target instanceof Element)) return - if (target.closest("input, textarea, [contenteditable='true']")) return - searchRef?.focus() - }} - > - - { - searchRef = el - }} - class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak" - style={{ "box-shadow": "none" }} - value={search.value} - onInput={(event) => setSearch("value", event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault() - setSearch("value", "") - queueMicrotask(() => searchRef?.focus()) - return - } - - if (!searching()) return - - if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") { - const ref = listRef - if (!ref) return - event.stopPropagation() - ref.onKeyDown(event) - return - } - - if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { - if (event.key === "n" || event.key === "p") { - const ref = listRef - if (!ref) return - event.stopPropagation() - ref.onKeyDown(event) - } - } - }} - placeholder={language.t("session.header.search.placeholder", { project: projectName() })} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - /> - - { - setSearch("value", "") - queueMicrotask(() => searchRef?.focus()) - }} - /> - -
-
- - - `${item.directory}:${item.id}`} - onSelect={open} - ref={(ref) => { - listRef = ref - }} - > - {(item) => ( -
- - {item.title} - - - {item.label} - -
- )} -
-
- -
+
0 && providers.paid().length === 0), + hidden: !(providers.all().length > 0 && providers.paid().length === 0), }} >
From 0d557721cffc2cfb17e3b40354f94f5ee00393d0 Mon Sep 17 00:00:00 2001 From: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:49:07 +0530 Subject: [PATCH 09/47] fix(app): edit project dialog icon on hover (#11921) --- packages/app/src/components/dialog-edit-project.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 490753e6224..622daee7a3e 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
- +
- +
From 08671e315569b8fa82c1137db194b9ee9fa20ba4 Mon Sep 17 00:00:00 2001 From: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:49:56 +0530 Subject: [PATCH 10/47] fix(app): session tabs to open the previous opened (#11914) --- packages/app/src/context/layout.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index d30fd11cfb7..28fe628a803 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -682,12 +682,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (!current) return const all = current.all.filter((x) => x !== tab) + if (current.active !== tab) { + setStore("sessionTabs", session, "all", all) + return + } + + const index = current.all.findIndex((f) => f === tab) + const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0] batch(() => { setStore("sessionTabs", session, "all", all) - if (current.active !== tab) return - - const index = current.all.findIndex((f) => f === tab) - const next = all[index - 1] ?? all[0] setStore("sessionTabs", session, "active", next) }) }, From a38bae684fec495c4902d45af3af74b777f84abd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:01:12 -0600 Subject: [PATCH 11/47] chore(app): don't forceMount tooltips --- packages/app/src/components/dialog-select-model.tsx | 5 ++--- packages/app/src/components/file-tree.tsx | 1 - packages/ui/src/components/tooltip.tsx | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 4f0dcc3ee65..3d0d6c7938c 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -54,7 +54,6 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} - forceMount={false} value={ (props: { class="p-1" action={
- + (props: { onClick={handleConnectProvider} /> - + {local.children} - + {c()} From e88cbefabeba25f90081541fc6e20d403c1bb171 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 06:34:23 -0600 Subject: [PATCH 12/47] fix(app): terminal serialization bug --- packages/app/src/addons/serialize.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 4309a725e51..f657887852c 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -442,12 +442,24 @@ class StringSerializeHandler extends BaseSerializeHandler { } } - if (!excludeFinalCursorPosition) { - const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY - const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) - const cursorCol = this._buffer.cursorX + 1 - content += `\u001b[${cursorRow};${cursorCol}H` - } + if (excludeFinalCursorPosition) return content + + const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY + const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) + const cursorCol = this._buffer.cursorX + 1 + content += `\u001b[${cursorRow};${cursorCol}H` + + const line = this._buffer.getLine(absoluteCursorRow) + const cell = line?.getCell(this._buffer.cursorX) + const style = (() => { + if (!cell) return this._buffer.getNullCell() + if (cell.getWidth() !== 0) return cell + if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell + return cell + })() + + const sgrSeq = this._diffStyle(style, this._cursorStyle) + if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m` return content } From 7508839b708844df3b6fc5afec641e3f2beb4f9d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:03:25 -0600 Subject: [PATCH 13/47] fix(app): terminal serialization bug --- packages/app/src/addons/serialize.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index f657887852c..3f0a8fb0aae 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler { protected _rowEnd(row: number, isLastRow: boolean): void { let rowSeparator = "" - if (this._nullCellCount > 0) { + const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1) + const wrapped = !!nextLine?.isWrapped + + if (this._nullCellCount > 0 && wrapped) { this._currentRow += " ".repeat(this._nullCellCount) - this._nullCellCount = 0 } - if (!isLastRow) { - const nextLine = this._buffer.getLine(row + 1) + this._nullCellCount = 0 - if (!nextLine?.isWrapped) { - rowSeparator = "\r\n" - this._lastCursorRow = row + 1 - this._lastCursorCol = 0 - } + if (!isLastRow && !wrapped) { + rowSeparator = "\r\n" + this._lastCursorRow = row + 1 + this._lastCursorCol = 0 } this._allRows[this._rowIndex] = this._currentRow @@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler { const sgrSeq = this._diffStyle(cell, this._cursorStyle) - const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0 + const styleChanged = sgrSeq.length > 0 if (styleChanged) { if (this._nullCellCount > 0) { From 95d0d476e306e0767e569466e39152390e7024d1 Mon Sep 17 00:00:00 2001 From: Luiz Guilherme D'Abruzzo Pereira <707366+luiz290788@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:26:42 -0300 Subject: [PATCH 14/47] docs: add --mdns-domain flag documentation (#11933) --- packages/web/src/content/docs/config.mdx | 2 ++ packages/web/src/content/docs/server.mdx | 13 +++++++------ packages/web/src/content/docs/web.mdx | 6 ++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 1474cb91558..5cc9d8666a9 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -190,6 +190,7 @@ You can configure server settings for the `opencode serve` and `opencode web` co "port": 4096, "hostname": "0.0.0.0", "mdns": true, + "mdnsDomain": "myproject.local", "cors": ["http://localhost:5173"] } } @@ -200,6 +201,7 @@ Available options: - `port` - Port to listen on. - `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`. - `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server. +- `mdnsDomain` - Custom domain name for mDNS service. Defaults to `opencode.local`. Useful for running multiple instances on the same network. - `cors` - Additional origins to allow for CORS when using the HTTP server from a browser-based client. Values must be full origins (scheme + host + optional port), eg `https://app.example.com`. [Learn more about the server here](/docs/server). diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 7229e09b22f..4510bd4981f 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -18,12 +18,13 @@ opencode serve [--port ] [--hostname ] [--cors ] #### Options -| Flag | Description | Default | -| ------------ | ----------------------------------- | ----------- | -| `--port` | Port to listen on | `4096` | -| `--hostname` | Hostname to listen on | `127.0.0.1` | -| `--mdns` | Enable mDNS discovery | `false` | -| `--cors` | Additional browser origins to allow | `[]` | +| Flag | Description | Default | +| --------------- | ----------------------------------- | ---------------- | +| `--port` | Port to listen on | `4096` | +| `--hostname` | Hostname to listen on | `127.0.0.1` | +| `--mdns` | Enable mDNS discovery | `false` | +| `--mdns-domain` | Custom domain name for mDNS service | `opencode.local` | +| `--cors` | Additional browser origins to allow | `[]` | `--cors` can be passed multiple times: diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx index 1013712f3a5..52b97460c49 100644 --- a/packages/web/src/content/docs/web.mdx +++ b/packages/web/src/content/docs/web.mdx @@ -64,6 +64,12 @@ opencode web --mdns This automatically sets the hostname to `0.0.0.0` and advertises the server as `opencode.local`. +You can customize the mDNS domain name to run multiple instances on the same network: + +```bash +opencode web --mdns --mdns-domain myproject.local +``` + ### CORS To allow additional domains for CORS (useful for custom frontends): From 76381f33d5b25f01867034767ab30650cb10fd32 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Tue, 3 Feb 2026 12:06:02 -0300 Subject: [PATCH 15/47] feat(desktop): Allow empty prompt with review comments (#11953) --- packages/app/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 619d4e5d92e..ac3c74589cd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1132,7 +1132,7 @@ export const PromptInput: Component = (props) => { const images = imageAttachments().slice() const mode = store.mode - if (text.trim().length === 0 && images.length === 0) { + if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) { if (working()) abort() return } @@ -2068,7 +2068,7 @@ export const PromptInput: Component = (props) => { > Date: Tue, 3 Feb 2026 22:24:52 +0700 Subject: [PATCH 16/47] fix: handle nested array items for Gemini schema validation (#11952) --- packages/opencode/src/provider/transform.ts | 11 +- .../opencode/test/provider/transform.test.ts | 113 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b4f1aaca4d5..16f2bd3f2ab 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -768,8 +768,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) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0743049fe06..0e0bb440aa8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -293,6 +293,119 @@ describe("ProviderTransform.schema - gemini array items", () => { }) }) +describe("ProviderTransform.schema - gemini nested array items", () => { + const geminiModel = { + providerID: "google", + api: { + id: "gemini-3-pro", + }, + } as any + + test("adds type to 2D array with empty inner items", () => { + const schema = { + type: "object", + properties: { + values: { + type: "array", + items: { + type: "array", + items: {}, // Empty items object + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + // Inner items should have a default type + expect(result.properties.values.items.items.type).toBe("string") + }) + + test("adds items and type to 2D array with missing inner items", () => { + const schema = { + type: "object", + properties: { + data: { + type: "array", + items: { type: "array" }, // No items at all + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.data.items.items).toBeDefined() + expect(result.properties.data.items.items.type).toBe("string") + }) + + test("handles deeply nested arrays (3D)", () => { + const schema = { + type: "object", + properties: { + matrix: { + type: "array", + items: { + type: "array", + items: { + type: "array", + // No items + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.matrix.items.items.items).toBeDefined() + expect(result.properties.matrix.items.items.items.type).toBe("string") + }) + + test("preserves existing item types in nested arrays", () => { + const schema = { + type: "object", + properties: { + numbers: { + type: "array", + items: { + type: "array", + items: { type: "number" }, // Has explicit type + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + // Should preserve the explicit type + expect(result.properties.numbers.items.items.type).toBe("number") + }) + + test("handles mixed nested structures with objects and arrays", () => { + const schema = { + type: "object", + properties: { + spreadsheetData: { + type: "object", + properties: { + rows: { + type: "array", + items: { + type: "array", + items: {}, // Empty items + }, + }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(geminiModel, schema) as any + + expect(result.properties.spreadsheetData.properties.rows.items.items.type).toBe("string") + }) +}) + describe("ProviderTransform.schema - gemini non-object properties removal", () => { const geminiModel = { providerID: "google", From 54e14c1a176d3e985216e249d2f48266d5a55d55 Mon Sep 17 00:00:00 2001 From: neavo Date: Tue, 3 Feb 2026 23:26:37 +0800 Subject: [PATCH 17/47] fix: exclude k2p5 from reasoning variants (#11918) --- packages/opencode/src/provider/transform.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 16f2bd3f2ab..2879f7edafc 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -333,7 +333,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 {} From 39753296292e594da0568a230bafa368888d4614 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 3 Feb 2026 09:58:31 -0600 Subject: [PATCH 18/47] feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (#11737) --- .opencode/opencode.jsonc | 1 - packages/opencode/src/agent/agent.ts | 3 + packages/opencode/src/skill/skill.ts | 15 ++++- packages/opencode/src/tool/skill.ts | 67 ++++++++++++++++++---- packages/opencode/test/agent/agent.test.ts | 37 ++++++++++++ packages/opencode/test/skill/skill.test.ts | 36 ++++++++++++ 6 files changed, 143 insertions(+), 16 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 52fd004324a..e2350c907b5 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,6 +1,5 @@ { "$schema": "https://opencode.ai/config.json", - // "plugin": ["opencode-openai-codex-auth"], // "enterprise": { // "url": "https://enterprise.dev.opencode.ai", // }, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0eaa410e0f6..e338559be7e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -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", diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 6e05d013ae5..7ffc4d8a15f 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -145,14 +145,23 @@ export namespace Skill { } } - return skills + const dirs = Array.from(new Set(Object.values(skills).map((item) => path.dirname(item.location)))) + + return { + skills, + 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) } } diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 8f285d5999a..8fcfb592dee 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -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 `` 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:", + "", "", ...accessibleSkills.flatMap((skill) => [ ` `, ` ${skill.name}`, ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, ` `, ]), "", - ].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}`).join("\n")) return { title: `Loaded skill: ${skill.name}`, - output, + output: [ + ``, + `# 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.", + "", + "", + files, + "", + "", + ].join("\n"), metadata: { name: skill.name, dir, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 05b8427394b..5e91059ffb3 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,4 +1,5 @@ import { test, expect } from "bun:test" +import path from "path" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" @@ -513,6 +514,42 @@ test("explicit Truncate.GLOB deny is respected", async () => { }) }) +test("skill directories are allowed for external_directory", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, ".opencode", "skill", "perm-skill") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: perm-skill +description: Permission skill. +--- + +# Permission Skill +`, + ) + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") + const target = path.join(skillDir, "reference", "notes.md") + expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow") + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } +}) + test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() await Instance.provide({ diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 72415c1411e..1d4828580ae 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -55,6 +55,42 @@ Instructions here. }) }) +test("returns skill directories from Skill.dirs", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, ".opencode", "skill", "dir-skill") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: dir-skill +description: Skill for dirs test. +--- + +# Dir Skill +`, + ) + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const dirs = await Skill.dirs() + const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill") + expect(dirs).toContain(skillDir) + expect(dirs.length).toBe(1) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } +}) + test("discovers multiple skills from .opencode/skill/ directory", async () => { await using tmp = await tmpdir({ git: true, From 857b8a4b568debb8ecf5312991204cb7a87a3e5b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:05:10 -0600 Subject: [PATCH 19/47] test: add test for new skill improvements (#11969) --- packages/opencode/test/tool/skill.test.ts | 112 ++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/opencode/test/tool/skill.test.ts diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts new file mode 100644 index 00000000000..d5057ba9e7f --- /dev/null +++ b/packages/opencode/test/tool/skill.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import type { PermissionNext } from "../../src/permission/next" +import type { Tool } from "../../src/tool/tool" +import { Instance } from "../../src/project/instance" +import { SkillTool } from "../../src/tool/skill" +import { tmpdir } from "../fixture/fixture" + +const baseCtx: Omit = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, +} + +describe("tool.skill", () => { + test("description lists skill location URL", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, ".opencode", "skill", "tool-skill") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: tool-skill +description: Skill for tool tests. +--- + +# Tool Skill +`, + ) + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SkillTool.init() + const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md") + expect(tool.description).toContain(`${pathToFileURL(skillPath).href}`) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } + }) + + test("execute returns skill content block with files", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, ".opencode", "skill", "tool-skill") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: tool-skill +description: Skill for tool tests. +--- + +# Tool Skill + +Use this skill. +`, + ) + await Bun.write(path.join(skillDir, "scripts", "demo.txt"), "demo") + }, + }) + + const home = process.env.OPENCODE_TEST_HOME + process.env.OPENCODE_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await SkillTool.init() + const requests: Array> = [] + const ctx: Tool.Context = { + ...baseCtx, + ask: async (req) => { + requests.push(req) + }, + } + + const result = await tool.execute({ name: "tool-skill" }, ctx) + const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill") + const file = path.resolve(dir, "scripts", "demo.txt") + + expect(requests.length).toBe(1) + expect(requests[0].permission).toBe("skill") + expect(requests[0].patterns).toContain("tool-skill") + expect(requests[0].always).toContain("tool-skill") + + expect(result.metadata.dir).toBe(dir) + expect(result.output).toContain(``) + expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(dir).href}`) + expect(result.output).toContain(`${file}`) + }, + }) + } finally { + process.env.OPENCODE_TEST_HOME = home + } + }) +}) From e33eb1b058028f88d2d397215ee2a20c82bb1ece Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:07:02 -0600 Subject: [PATCH 20/47] chore: cleanup --- packages/app/src/pages/session.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 540046c09b9..8da398d7dcf 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1950,7 +1950,7 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), }} > @@ -1979,7 +1979,7 @@ export default function Page() { class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), "mt-0.5": centered(), "mt-0": !centered(), From 017ad2c7f71463c4c98348f9010393e79a8a54cc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:27:48 -0600 Subject: [PATCH 21/47] fix(app): spacing --- packages/app/src/pages/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 8da398d7dcf..f1539ba2559 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1976,7 +1976,7 @@ export default function Page() {
Date: Tue, 3 Feb 2026 11:44:03 -0600 Subject: [PATCH 22/47] chore: gitignore codex --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 78a77f81982..ce3d19e778c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .env .idea .vscode +.codex *~ playground tmp From 60e616ec8150d100a17df758963eb023b8f50d95 Mon Sep 17 00:00:00 2001 From: Alex <10480967+workedbeforepush@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:46:04 +0100 Subject: [PATCH 23/47] feat: Add .slnx to C#/F# LSP root detection (#11928) --- packages/opencode/src/lsp/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e7efd99dcbd..b0755b8b563 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -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", { From 4f7da2b757509d8a7c2f8ad5cc64bb0d38032beb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:51:21 -0600 Subject: [PATCH 24/47] fix(app): model selector truncating too soon --- packages/app/src/components/prompt-input.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ac3c74589cd..6b568e9160b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1934,13 +1934,13 @@ export const PromptInput: Component = (props) => {