From 2946a6d9a78466c19637cb75473769b1deb6f03e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 26 Dec 2025 12:10:30 +0000 Subject: [PATCH 001/101] ignore: update download stats 2025-12-26 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index d3c6f57ad40..77a9d296cfc 100644 --- a/STATS.md +++ b/STATS.md @@ -181,3 +181,4 @@ | 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | | 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | | 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | +| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | From cf388847786fca1c9820f2a386056ddf0dce08c0 Mon Sep 17 00:00:00 2001 From: Didier Durand <2927957+didier-durand@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:21:33 +0100 Subject: [PATCH 002/101] doc: fix typos in various files (#6196) --- README.md | 4 ++-- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- .../opencode/src/session/prompt/plan-reminder-anthropic.txt | 2 +- packages/opencode/src/tool/grep.txt | 2 +- packages/web/astro.config.mjs | 2 +- packages/web/src/content/docs/formatters.mdx | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5295810b6f0..b68195abdbe 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ you can switch between these using the `Tab` key. - Asks permission before running bash commands - Ideal for exploring unfamiliar codebases or planning changes -Also, included is a **general** subagent for complex searches and multi-step tasks. +Also, included is a **general** subagent for complex searches and multistep tasks. This is used internally and can be invoked using `@general` in messages. Learn more about [agents](https://opencode.ai/docs/agents). @@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod ### FAQ -#### How is this different than Claude Code? +#### How is this different from Claude Code? It's very similar to Claude Code in terms of capability. Here are the key differences: diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 13c95d9b9ea..5214b0c1a9a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -539,7 +539,7 @@ function App() { sdk.event.on(SessionApi.Event.Error.type, (evt) => { const error = evt.properties.error const message = (() => { - if (!error) return "An error occured" + if (!error) return "An error occurred" if (typeof error === "object") { const data = error.data diff --git a/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt b/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt index a5c2f267e07..28f1e629dbe 100644 --- a/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt +++ b/packages/opencode/src/session/prompt/plan-reminder-anthropic.txt @@ -1,7 +1,7 @@ # Plan Mode - System Reminder -Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received. +Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. --- diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index 6067ef27b9d..adf583695ae 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -5,4 +5,4 @@ - Returns file paths and line numbers with at least one match sorted by modification time - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. -- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead +- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 7ecf2bfd9d6..dba43d02fa3 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -36,7 +36,7 @@ export default defineConfig({ expressiveCode: { themes: ["github-light", "github-dark"] }, social: [ { icon: "github", label: "GitHub", href: config.github }, - { icon: "discord", label: "Dscord", href: config.discord }, + { icon: "discord", label: "Discord", href: config.discord }, ], editLink: { baseUrl: `${config.github}/edit/dev/packages/web/`, diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index c2c01836bb3..885a95da914 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -30,7 +30,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | | terraform | .tf, .tfvars | `terraform` command available | | gleam | .gleam | `gleam` command available | -| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experiental env variable flag](/docs/cli/#experimental) | +| oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. From dd569c927a1795804d4f3bd6753ad904c4723252 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 26 Dec 2025 16:22:05 +0000 Subject: [PATCH 003/101] chore: generate --- packages/web/src/content/docs/formatters.mdx | 38 ++++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 885a95da914..fa915f116d4 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -11,25 +11,25 @@ OpenCode automatically formats files after they are written or edited using lang OpenCode comes with several built-in formatters for popular languages and frameworks. Below is a list of the formatters, supported file extensions, and commands or config options it needs. -| Formatter | Extensions | Requirements | -| -------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| gofmt | .go | `gofmt` command available | -| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available | -| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` | -| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file | -| zig | .zig, .zon | `zig` command available | -| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file | -| ktlint | .kt, .kts | `ktlint` command available | -| ruff | .py, .pyi | `ruff` command available with config | -| uv | .py, .pyi | `uv` command available | -| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | -| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | -| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | -| air | .R | `air` command available | -| dart | .dart | `dart` command available | -| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | -| terraform | .tf, .tfvars | `terraform` command available | -| gleam | .gleam | `gleam` command available | +| Formatter | Extensions | Requirements | +| -------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| gofmt | .go | `gofmt` command available | +| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` command available | +| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://prettier.io/docs/en/index.html) | `prettier` dependency in `package.json` | +| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml, and [more](https://biomejs.dev/) | `biome.json(c)` config file | +| zig | .zig, .zon | `zig` command available | +| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file | +| ktlint | .kt, .kts | `ktlint` command available | +| ruff | .py, .pyi | `ruff` command available with config | +| uv | .py, .pyi | `uv` command available | +| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available | +| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available | +| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` command available | +| air | .R | `air` command available | +| dart | .dart | `dart` command available | +| ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | +| terraform | .tf, .tfvars | `terraform` command available | +| gleam | .gleam | `gleam` command available | | oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. From 26e7043718fbf5fbf08eecd04ff8ed5edd82e33e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:29:48 -0600 Subject: [PATCH 004/101] feat(core): optional mdns service (#6192) Co-authored-by: Github Action --- bun.lock | 11 ++++ flake.lock | 6 +-- nix/hashes.json | 2 +- packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/acp.ts | 30 ++++------- packages/opencode/src/cli/cmd/serve.ts | 25 +++------ packages/opencode/src/cli/cmd/tui/spawn.ts | 28 ++++------ packages/opencode/src/cli/cmd/tui/thread.ts | 22 +++----- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 31 ++++------- packages/opencode/src/cli/network.ts | 42 +++++++++++++++ packages/opencode/src/config/config.ts | 12 +++++ packages/opencode/src/server/mdns.ts | 57 +++++++++++++++++++++ packages/opencode/src/server/server.ts | 33 ++++++++++-- packages/web/src/content/docs/cli.mdx | 18 ++++--- packages/web/src/content/docs/config.mdx | 25 +++++++++ packages/web/src/content/docs/server.mdx | 9 ++-- 17 files changed, 238 insertions(+), 116 deletions(-) create mode 100644 packages/opencode/src/cli/network.ts create mode 100644 packages/opencode/src/server/mdns.ts diff --git a/bun.lock b/bun.lock index 796cd5661e4..818dee4f228 100644 --- a/bun.lock +++ b/bun.lock @@ -292,6 +292,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", @@ -1081,6 +1082,8 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -2003,6 +2006,8 @@ "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], + "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -2247,6 +2252,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -3023,6 +3030,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], @@ -3595,6 +3604,8 @@ "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], diff --git a/flake.lock b/flake.lock index 4ff2c1d0e11..8bba6eeb3df 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766532406, - "narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", + "lastModified": 1766747458, + "narHash": "sha256-m63jjuo/ygo8ztkCziYh5OOIbTSXUDkKbqw3Vuqu4a4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8142186f001295e5a3239f485c8a49bf2de2695a", + "rev": "c633f572eded8c4f3c75b8010129854ed404a6ce", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index 66c0baaf791..4363085de3b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME=" + "nodeModules": "sha256-okbViEKf1mRSmzbJgKdB9SJ875q84Bwu8d3ChHuaQ1g=" } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f04f0bd8715..ef2b822e86e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -81,6 +81,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index c607e5f5bb7..2db64e3b1af 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,8 +3,10 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" +import { Config } from "@/config/config" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) @@ -19,29 +21,17 @@ export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return yargs - .option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }) + return withNetworkOptions(yargs).option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const server = Server.listen({ - port: args.port, - hostname: args.hostname, - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3af3316a9d3..0fd7aa88f32 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,29 +1,16 @@ +import { Config } from "../../config/config" import { Server } from "../../server/server" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) await server.stop() diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index fa679529890..7ab846428d7 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -1,33 +1,23 @@ import { cmd } from "@/cli/cmd/cmd" +import { Config } from "@/config/config" import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" import { upgrade } from "@/cli/upgrade" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" export const TuiSpawnCommand = cmd({ command: "spawn [project]", builder: (yargs) => - yargs - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + withNetworkOptions(yargs).positional("project", { + type: "string", + describe: "path to start opencode in", + }), handler: async (args) => { upgrade() - const server = Server.listen({ - port: args.port, - hostname: "127.0.0.1", - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) const bin = process.execPath const cmd = [] let cwd = process.cwd() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3cf8937a974..f75e3bd6511 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -6,6 +6,8 @@ import path from "path" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { Config } from "@/config/config" declare global { const OPENCODE_WORKER_PATH: string @@ -15,7 +17,7 @@ export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - yargs + withNetworkOptions(yargs) .positional("project", { type: "string", describe: "path to start opencode in", @@ -36,23 +38,12 @@ export const TuiThreadCommand = cmd({ describe: "session id to continue", }) .option("prompt", { - alias: ["p"], type: "string", describe: "prompt to use", }) .option("agent", { type: "string", describe: "agent to use", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", }), handler: async (args) => { // Resolve relative paths against PWD to preserve behavior when using --cwd flag @@ -87,10 +78,9 @@ export const TuiThreadCommand = cmd({ process.on("unhandledRejection", (e) => { Log.Default.error(e) }) - const server = await client.call("server", { - port: args.port, - hostname: args.hostname, - }) + const config = await Config.get() + const networkOpts = resolveNetworkOptions(args, config) + const server = await client.call("server", networkOpts) const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 76f78f3faa8..3ffc45ae884 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -30,7 +30,7 @@ process.on("uncaughtException", (e) => { let server: Bun.Server export const rpc = { - async server(input: { port: number; hostname: string }) { + async server(input: { port: number; hostname: string; mdns?: boolean }) { if (server) await server.stop(true) try { server = Server.listen(input) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 3d3036b1b07..adede03859c 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,8 @@ +import { Config } from "../../config/config" import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" import open from "open" import { networkInterfaces } from "os" @@ -28,32 +30,17 @@ function getNetworkIPs() { export const WebCommand = cmd({ command: "web", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) UI.empty() - if (hostname === "0.0.0.0") { + if (opts.hostname === "0.0.0.0") { // Show localhost for local access const localhostUrl = `http://localhost:${server.port}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) @@ -70,6 +57,10 @@ export const WebCommand = cmd({ } } + if (opts.mdns) { + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + } + // Open localhost in browser open(localhostUrl.toString()).catch(() => {}) } else { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts new file mode 100644 index 00000000000..661688572bd --- /dev/null +++ b/packages/opencode/src/cli/network.ts @@ -0,0 +1,42 @@ +import type { Argv, InferredOptionTypes } from "yargs" +import type { Config } from "../config/config" + +const options = { + port: { + type: "number" as const, + describe: "port to listen on", + default: 0, + }, + hostname: { + type: "string" as const, + describe: "hostname to listen on", + default: "127.0.0.1", + }, + mdns: { + type: "boolean" as const, + describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", + default: false, + }, +} + +export type NetworkOptions = InferredOptionTypes + +export function withNetworkOptions(yargs: Argv) { + return yargs.options(options) +} + +export function resolveNetworkOptions(args: NetworkOptions, config?: Config.Info) { + const portExplicitlySet = process.argv.includes("--port") + const hostnameExplicitlySet = process.argv.includes("--hostname") + const mdnsExplicitlySet = process.argv.includes("--mdns") + + const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) + const hostname = hostnameExplicitlySet + ? args.hostname + : mdns && !config?.server?.hostname + ? "0.0.0.0" + : (config?.server?.hostname ?? args.hostname) + + return { hostname, port, mdns } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba9d1973025..9187ed6745a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -587,6 +587,17 @@ export namespace Config { .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), }) + export const Server = z + .object({ + port: z.number().int().positive().optional().describe("Port to listen on"), + hostname: z.string().optional().describe("Hostname to listen on"), + mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + }) + .strict() + .meta({ + ref: "ServerConfig", + }) + export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) @@ -634,6 +645,7 @@ export namespace Config { theme: z.string().optional().describe("Theme name to use for the interface"), keybinds: Keybinds.optional().describe("Custom keybind configurations"), tui: TUI.optional().describe("TUI specific settings"), + server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z .record(z.string(), Command) .optional() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts new file mode 100644 index 00000000000..45e61d361ac --- /dev/null +++ b/packages/opencode/src/server/mdns.ts @@ -0,0 +1,57 @@ +import { Log } from "@/util/log" +import Bonjour from "bonjour-service" + +const log = Log.create({ service: "mdns" }) + +export namespace MDNS { + let bonjour: Bonjour | undefined + let currentPort: number | undefined + + export function publish(port: number, name = "opencode") { + if (currentPort === port) return + if (bonjour) unpublish() + + try { + bonjour = new Bonjour() + const service = bonjour.publish({ + name, + type: "http", + port, + txt: { path: "/" }, + }) + + service.on("up", () => { + log.info("mDNS service published", { name, port }) + }) + + service.on("error", (err) => { + log.error("mDNS service error", { error: err }) + }) + + currentPort = port + } catch (err) { + log.error("mDNS publish failed", { error: err }) + if (bonjour) { + try { + bonjour.destroy() + } catch {} + } + bonjour = undefined + currentPort = undefined + } + } + + export function unpublish() { + if (bonjour) { + try { + bonjour.unpublishAll() + bonjour.destroy() + } catch (err) { + log.error("mDNS unpublish failed", { error: err }) + } + bonjour = undefined + currentPort = undefined + log.info("mDNS service unpublished") + } + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c74dbbb41ef..65393e12897 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,9 +45,11 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket } from "hono/bun" +import type { BunWebSocketData } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" import { Installation } from "@/installation" +import { MDNS } from "./mdns" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -2623,20 +2625,41 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string }) { + export function listen(opts: { port: number; hostname: string; mdns?: boolean }) { const args = { hostname: opts.hostname, idleTimeout: 0, fetch: App().fetch, websocket: websocket, } as const - if (opts.port === 0) { + const tryServe = (port: number) => { try { - return Bun.serve({ ...args, port: 4096 }) + return Bun.serve({ ...args, port }) } catch { - // port 4096 not available, fall through to use port 0 + return undefined } } - return Bun.serve({ ...args, port: opts.port }) + const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) + if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + + const shouldPublishMDNS = + opts.mdns && + server.port && + opts.hostname !== "127.0.0.1" && + opts.hostname !== "localhost" && + opts.hostname !== "::1" + if (shouldPublishMDNS) { + MDNS.publish(server.port!) + } else if (opts.mdns) { + log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + + const originalStop = server.stop.bind(server) + server.stop = async (closeActiveConnections?: boolean) => { + if (shouldPublishMDNS) MDNS.unpublish() + return originalStop(closeActiveConnections) + } + + return server } } diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index e4e40ac7a4c..4a826e5b3ff 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -335,10 +335,11 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------- | -| `--port` | `-p` | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Description | +| ------------ | --------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | --- @@ -428,10 +429,11 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------- | -| `--port` | `-p` | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Description | +| ------------ | --------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index ebaff36bb15..d7f8031782c 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -120,6 +120,31 @@ Available options: --- +### Server + +You can configure server settings for the `opencode serve` and `opencode web` commands through the `server` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true + } +} +``` + +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. + +[Learn more about the server here](/docs/server). + +--- + ### Tools You can manage the tools an LLM can use through the `tools` option. diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 427d8f505ff..c63917f792e 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -18,10 +18,11 @@ opencode serve [--port ] [--hostname ] #### Options -| Flag | Short | Description | Default | -| ------------ | ----- | --------------------- | ----------- | -| `--port` | `-p` | Port to listen on | `4096` | -| `--hostname` | `-h` | Hostname to listen on | `127.0.0.1` | +| Flag | Description | Default | +| ------------ | --------------------- | ----------- | +| `--port` | Port to listen on | `4096` | +| `--hostname` | Hostname to listen on | `127.0.0.1` | +| `--mdns` | Enable mDNS discovery | `false` | --- From 54588b4570728749705ebba027a07129a47e86b9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 26 Dec 2025 16:30:20 +0000 Subject: [PATCH 005/101] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 19 +++++++++++++++++++ packages/sdk/openapi.json | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0a31394ed9c..87aac3287e0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1151,6 +1151,24 @@ export type KeybindsConfig = { tips_toggle?: string } +/** + * Server configuration for opencode serve and web commands + */ +export type ServerConfig = { + /** + * Port to listen on + */ + port?: number + /** + * Hostname to listen on + */ + hostname?: string + /** + * Enable mDNS service discovery + */ + mdns?: boolean +} + export type AgentConfig = { model?: string temperature?: number @@ -1410,6 +1428,7 @@ export type Config = { */ diff_style?: "auto" | "stacked" } + server?: ServerConfig /** * Command configuration, see https://opencode.ai/docs/commands */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 96ba0720c73..dd20a93ec1b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7687,6 +7687,27 @@ }, "additionalProperties": false }, + "ServerConfig": { + "description": "Server configuration for opencode serve and web commands", + "type": "object", + "properties": { + "port": { + "description": "Port to listen on", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "hostname": { + "description": "Hostname to listen on", + "type": "string" + }, + "mdns": { + "description": "Enable mDNS service discovery", + "type": "boolean" + } + }, + "additionalProperties": false + }, "AgentConfig": { "type": "object", "properties": { @@ -8170,6 +8191,9 @@ } } }, + "server": { + "$ref": "#/components/schemas/ServerConfig" + }, "command": { "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", From 2333af6ed3085721cdc244b153097bd0d1b353a8 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 26 Dec 2025 13:49:05 -0300 Subject: [PATCH 006/101] Desktop: MCP UI (#6162) Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> --- .../app/src/components/dialog-select-mcp.tsx | 91 +++++++++++++++++++ .../src/components/session-lsp-indicator.tsx | 40 ++++++++ .../src/components/session-mcp-indicator.tsx | 36 ++++++++ packages/app/src/components/status-bar.tsx | 14 +++ packages/app/src/context/global-sync.tsx | 10 ++ packages/app/src/pages/session.tsx | 17 ++++ packages/ui/src/components/icon.tsx | 1 + 7 files changed, 209 insertions(+) create mode 100644 packages/app/src/components/dialog-select-mcp.tsx create mode 100644 packages/app/src/components/session-lsp-indicator.tsx create mode 100644 packages/app/src/components/session-mcp-indicator.tsx create mode 100644 packages/app/src/components/status-bar.tsx diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx new file mode 100644 index 00000000000..c29cd827e3b --- /dev/null +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -0,0 +1,91 @@ +import { Component, createMemo, createSignal, Show } from "solid-js" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogSelectMcp: Component = () => { + const sync = useSync() + const sdk = useSDK() + const [loading, setLoading] = createSignal(null) + + const items = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) + } + + const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => items().length) + + return ( + + x?.name ?? ""} + items={items} + filterKeys={["name", "status"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + onSelect={(x) => { + if (x) toggle(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const error = () => { + const s = mcpStatus() + return s?.status === "failed" ? s.error : undefined + } + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} + + connected + + + failed + + + needs auth + + + disabled + + + ... + +
+ + {error()} + +
+
e.stopPropagation()}> + toggle(i.name)} /> +
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx new file mode 100644 index 00000000000..98d6d6dfd76 --- /dev/null +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -0,0 +1,40 @@ +import { createMemo, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useSync } from "@/context/sync" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +export function SessionLspIndicator() { + const sync = useSync() + + const lspStats = createMemo(() => { + const lsp = sync.data.lsp ?? [] + const connected = lsp.filter((s) => s.status === "connected").length + const hasError = lsp.some((s) => s.status === "error") + const total = lsp.length + return { connected, hasError, total } + }) + + const tooltipContent = createMemo(() => { + const lsp = sync.data.lsp ?? [] + if (lsp.length === 0) return "No LSP servers" + return lsp.map((s) => s.name).join(", ") + }) + + return ( + 0}> + +
+ 0, + }} + /> + {lspStats().connected} LSP +
+
+
+ ) +} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx new file mode 100644 index 00000000000..17a6f2e1af0 --- /dev/null +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -0,0 +1,36 @@ +import { createMemo, Show } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSync } from "@/context/sync" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" + +export function SessionMcpIndicator() { + const sync = useSync() + const dialog = useDialog() + + const mcpStats = createMemo(() => { + const mcp = sync.data.mcp ?? {} + const entries = Object.entries(mcp) + const enabled = entries.filter(([, status]) => status.status === "connected").length + const failed = entries.some(([, status]) => status.status === "failed") + const total = entries.length + return { enabled, failed, total } + }) + + return ( + 0}> + + + ) +} diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx new file mode 100644 index 00000000000..e0e25c60b8b --- /dev/null +++ b/packages/app/src/components/status-bar.tsx @@ -0,0 +1,14 @@ +import { Show, type ParentProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export function StatusBar(props: ParentProps) { + const platform = usePlatform() + return ( +
+ + v{platform.version} + +
{props.children}
+
+ ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 10607b1d23f..7a9dc8dc425 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -12,6 +12,8 @@ import { type ProviderListResponse, type ProviderAuthResponse, type Command, + type McpStatus, + type LspStatus, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -41,6 +43,10 @@ type State = { todo: { [sessionID: string]: Todo[] } + mcp: { + [name: string]: McpStatus + } + lsp: LspStatus[] limit: number message: { [sessionID: string]: Message[] @@ -85,6 +91,8 @@ function createGlobalSync() { session_status: {}, session_diff: {}, todo: {}, + mcp: {}, + lsp: [], limit: 5, message: {}, part: {}, @@ -149,6 +157,8 @@ function createGlobalSync() { session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), + lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d7eaccc2ad9..019cc305c1a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -49,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" @@ -56,6 +57,9 @@ import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { StatusBar } from "@/components/status-bar" +import { SessionMcpIndicator } from "@/components/session-mcp-indicator" +import { SessionLspIndicator } from "@/components/session-lsp-indicator" export default function Page() { const layout = useLayout() @@ -274,6 +278,15 @@ export default function Page() { slash: "model", onSelect: () => dialog.show(() => ), }, + { + id: "mcp.toggle", + title: "Toggle MCPs", + description: "Toggle MCPs", + category: "MCP", + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }, { id: "agent.cycle", title: "Cycle agent", @@ -921,6 +934,10 @@ export default function Page() { + + + + ) } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 45ccee8f9bf..5e1a8e32afc 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -18,6 +18,7 @@ const icons = { console: ``, expand: ``, collapse: ``, + code: ``, "code-lines": ``, "circle-ban-sign": ``, "edit-small-2": ``, From f1ab427f0efcdeaf5184f1f070eb2f4b0512edd8 Mon Sep 17 00:00:00 2001 From: Roberto Carvajal Date: Fri, 26 Dec 2025 14:08:45 -0300 Subject: [PATCH 007/101] fix(dep): Update package.json - fix perplexity provider version (#6199) Co-authored-by: Aiden Cline --- bun.lock | 14 +++++++------- package.json | 7 ------- packages/opencode/package.json | 7 +++++++ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index 818dee4f228..593033e34f4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,13 +5,6 @@ "": { "name": "opencode", "dependencies": { - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", - "@ai-sdk/togetherai": "1.0.30", "@aws-sdk/client-s3": "3.933.0", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", @@ -263,14 +256,21 @@ "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/azure": "2.0.73", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", "@ai-sdk/google": "2.0.44", "@ai-sdk/google-vertex": "3.0.81", + "@ai-sdk/groq": "2.0.33", "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", + "@ai-sdk/togetherai": "1.0.30", "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", diff --git a/package.json b/package.json index 7346a4ca853..aa7031bec72 100644 --- a/package.json +++ b/package.json @@ -67,13 +67,6 @@ "turbo": "2.5.6" }, "dependencies": { - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", - "@ai-sdk/togetherai": "1.0.30", "@aws-sdk/client-s3": "3.933.0", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ef2b822e86e..55656660e06 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -61,6 +61,13 @@ "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "@ai-sdk/xai": "2.0.42", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", + "@ai-sdk/groq": "2.0.33", + "@ai-sdk/perplexity": "2.0.22", + "@ai-sdk/togetherai": "1.0.30", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", From 155ba794cf974ed4473844c5611736178f1e5f7d Mon Sep 17 00:00:00 2001 From: Ayush Walekar Date: Fri, 26 Dec 2025 22:39:06 +0530 Subject: [PATCH 008/101] chore: createOpencodeServer expose `logLevel` (#6202) --- packages/opencode/src/config/config.ts | 1 + packages/sdk/js/src/gen/types.gen.ts | 4 ++++ packages/sdk/js/src/server.ts | 5 ++++- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/js/src/v2/server.ts | 5 ++++- 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9187ed6745a..3350cf8f6a6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -644,6 +644,7 @@ export namespace Config { $schema: z.string().optional().describe("JSON schema reference for configuration validation"), theme: z.string().optional().describe("Theme name to use for the interface"), keybinds: Keybinds.optional().describe("Custom keybind configurations"), + logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 06993d3f930..32f33f66219 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1174,6 +1174,10 @@ export type Config = { */ theme?: string keybinds?: KeybindsConfig + /** + * Log level + */ + logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" /** * TUI specific settings */ diff --git a/packages/sdk/js/src/server.ts b/packages/sdk/js/src/server.ts index a09e14ab2aa..174131ccfd5 100644 --- a/packages/sdk/js/src/server.ts +++ b/packages/sdk/js/src/server.ts @@ -28,7 +28,10 @@ export async function createOpencodeServer(options?: ServerOptions) { options ?? {}, ) - const proc = spawn(`opencode`, [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`], { + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + + const proc = spawn(`opencode`, args, { signal: options.signal, env: { ...process.env, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 87aac3287e0..2fda3375e78 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1406,6 +1406,10 @@ export type Config = { */ theme?: string keybinds?: KeybindsConfig + /** + * Log level + */ + logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" /** * TUI specific settings */ diff --git a/packages/sdk/js/src/v2/server.ts b/packages/sdk/js/src/v2/server.ts index a09e14ab2aa..174131ccfd5 100644 --- a/packages/sdk/js/src/v2/server.ts +++ b/packages/sdk/js/src/v2/server.ts @@ -28,7 +28,10 @@ export async function createOpencodeServer(options?: ServerOptions) { options ?? {}, ) - const proc = spawn(`opencode`, [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`], { + const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`] + if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`) + + const proc = spawn(`opencode`, args, { signal: options.signal, env: { ...process.env, From 634559760a99669446f0d17319c6a725427fe8a7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 26 Dec 2025 17:09:31 +0000 Subject: [PATCH 009/101] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 10 ++++++---- packages/sdk/openapi.json | 8 ++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 2fda3375e78..6b5cfc818d0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1151,6 +1151,11 @@ export type KeybindsConfig = { tips_toggle?: string } +/** + * Log level + */ +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" + /** * Server configuration for opencode serve and web commands */ @@ -1406,10 +1411,7 @@ export type Config = { */ theme?: string keybinds?: KeybindsConfig - /** - * Log level - */ - logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" + logLevel?: LogLevel /** * TUI specific settings */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index dd20a93ec1b..2a2984854f8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7687,6 +7687,11 @@ }, "additionalProperties": false }, + "LogLevel": { + "description": "Log level", + "type": "string", + "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + }, "ServerConfig": { "description": "Server configuration for opencode serve and web commands", "type": "object", @@ -8164,6 +8169,9 @@ "keybinds": { "$ref": "#/components/schemas/KeybindsConfig" }, + "logLevel": { + "$ref": "#/components/schemas/LogLevel" + }, "tui": { "description": "TUI specific settings", "type": "object", From d5b47d91282abbd61647ab67d62673ccccfdfb1a Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 26 Dec 2025 17:09:53 +0000 Subject: [PATCH 010/101] Update Nix flake.lock and hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 4363085de3b..f43b14684c3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-okbViEKf1mRSmzbJgKdB9SJ875q84Bwu8d3ChHuaQ1g=" + "nodeModules": "sha256-lloUZt5mLyNWAcbQrJB4wGUKvKu24WFEhOLfZD5/FMg=" } From 8416db03ef34a1ecf187faa81fb5ce7fe8ee4ec7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 11:23:31 -0600 Subject: [PATCH 011/101] tweak: make install script handle 404s better --- install | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/install b/install index e89ca9fb70f..702fb4a534c 100755 --- a/install +++ b/install @@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then exit 1 fi else + # Strip leading 'v' if present + requested_version="${requested_version#v}" url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename" specific_version=$requested_version + + # Verify the release exists before downloading + http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}") + if [ "$http_status" = "404" ]; then + echo -e "${RED}Error: Release v${requested_version} not found${NC}" + echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}" + exit 1 + fi fi print_message() { From b0e4408ecfd1f93bc95b7b46a8df861c13ee768a Mon Sep 17 00:00:00 2001 From: ja <51257127+anntnzrb@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:31:51 -0500 Subject: [PATCH 012/101] feat: add shfmt formatter for shell scripts (#6204) --- packages/opencode/src/format/formatter.ts | 9 +++++++++ packages/web/src/content/docs/formatters.mdx | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 954940f8db2..90c48b05c2a 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -313,3 +313,12 @@ export const gleam: Info = { return Bun.which("gleam") !== null }, } + +export const shfmt: Info = { + name: "shfmt", + command: ["shfmt", "-w", "$FILE"], + extensions: [".sh", ".bash"], + async enabled() { + return Bun.which("shfmt") !== null + }, +} diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index fa915f116d4..2c0687b8ea5 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -30,6 +30,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | ocamlformat | .ml, .mli | `ocamlformat` command available and `.ocamlformat` config file | | terraform | .tf, .tfvars | `terraform` command available | | gleam | .gleam | `gleam` command available | +| shfmt | .sh, .bash | `shfmt` command available | | oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. From 25c68c8061e42e6318ea4356ef023947e95d16de Mon Sep 17 00:00:00 2001 From: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:32:31 -0500 Subject: [PATCH 013/101] chore: kill the dead Polaris Alpha code (#6193) --- .../opencode/src/session/prompt/polaris.txt | 107 ------------------ packages/opencode/src/session/system.ts | 2 - 2 files changed, 109 deletions(-) delete mode 100644 packages/opencode/src/session/prompt/polaris.txt diff --git a/packages/opencode/src/session/prompt/polaris.txt b/packages/opencode/src/session/prompt/polaris.txt deleted file mode 100644 index f90761890da..00000000000 --- a/packages/opencode/src/session/prompt/polaris.txt +++ /dev/null @@ -1,107 +0,0 @@ -You are OpenCode, the best coding agent on the planet. - -You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. - -IMPORTANT: Do not guess arbitrary URLs. Only provide URLs you are confident are correct and directly helpful for programming (for example, well-known official documentation). Prefer URLs provided by the user in their messages or local files. - -If the user asks for help or wants to give feedback inform them of the following: -- ctrl+p to list available actions -- To give feedback, users should report the issue at - https://github.com/sst/opencode - -When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs. - -When the user asks in second person (eg. "are you able...", "can you do..."), treat it as a request to help. Briefly confirm your capability and, when appropriate, immediately start performing the requested task or provide a concrete, useful answer instead of replying with only "yes" or "no". - -# Tone and style -- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. -- Do not create new files unless necessary for achieving your goal or explicitly requested. Prefer editing an existing file when possible. This includes markdown files. - -# Professional objectivity -Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. - -# Task Management -You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools frequently for multi-step or non-trivial tasks to give the user visibility into your progress. -These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. - -Prefer marking todos as completed soon after you finish each task, rather than delaying without reason. - -Examples: - - -user: Run the build and fix any type errors -assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: -- Run the build -- Fix any type errors - -I'm now going to run the build using Bash. - -Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. - -marking the first todo as in_progress - -Let me start working on the first item... - -The first item has been fixed, let me mark the first todo as completed, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. - - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats - -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. - -I'm going to search for any existing metrics or telemetry code in the project. - -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... - -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] - - - -# Doing tasks -The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -- -- Use the TodoWrite tool to plan the task if required - -- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. - - -# Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. - -- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. -- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. -- Generally use the Task tool for broader or multi-file exploration; direct reads and searches are fine for specific, simple queries. - -user: Where are errors from the client handled? -assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] - - -user: What is the codebase structure? -assistant: [Uses the Task tool] - - -Prefer using the TodoWrite tool to plan and track tasks when there are multiple steps or files involved. - -# Code References - -When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. - - -user: Where are errors from the client handled? -assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. - diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 30094388168..429e696db3b 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -10,7 +10,6 @@ import os from "os" import PROMPT_ANTHROPIC from "./prompt/anthropic.txt" import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt" -import PROMPT_POLARIS from "./prompt/polaris.txt" import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" @@ -30,7 +29,6 @@ export namespace SystemPrompt { return [PROMPT_BEAST] if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC] - if (model.api.id.includes("polaris-alpha")) return [PROMPT_POLARIS] return [PROMPT_ANTHROPIC_WITHOUT_TODO] } From 1bcf8d88065cbd3183b5f44b98c6ca86cd5a7e3e Mon Sep 17 00:00:00 2001 From: Rohan Godha Date: Fri, 26 Dec 2025 11:36:31 -0600 Subject: [PATCH 014/101] fix: `opencode web` baseURL error (#6181) --- packages/app/src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 11216643e5b..de8fcf7d124 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -38,7 +38,7 @@ const url = iife(() => { if (import.meta.env.DEV) return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - return "http://localhost:4096" + return window.location.origin }) export function App() { From 4abaa052dbd33d15367deb1c8995eb2c6b764328 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 11:37:34 -0600 Subject: [PATCH 015/101] fix: adjust upgrade command to use gh releases page if not npm/bun/pnpm install method --- packages/opencode/src/installation/index.ts | 31 ++++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 2c99e21a8f5..effe19721b4 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -166,6 +166,8 @@ export namespace Installation { export async function latest(installMethod?: Method) { const detectedMethod = installMethod || (await method()) + + // Use brew formula API for homebrew core formula if (detectedMethod === "brew") { const formula = await getBrewFormula() if (formula === "opencode") { @@ -178,19 +180,28 @@ export namespace Installation { } } - const registry = await iife(async () => { - const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() - const reg = r || "https://registry.npmjs.org" - return reg.endsWith("/") ? reg.slice(0, -1) : reg - }) - const [major] = VERSION.split(".").map((x) => Number(x)) - // const channel = CHANNEL === "latest" ? `latest-${major}` : CHANNEL - const channel = CHANNEL - return fetch(`${registry}/opencode-ai/${channel}`) + // Use npm registry for npm/bun/pnpm + if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { + const registry = await iife(async () => { + const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() + const reg = r || "https://registry.npmjs.org" + return reg.endsWith("/") ? reg.slice(0, -1) : reg + }) + const channel = CHANNEL + return fetch(`${registry}/opencode-ai/${channel}`) + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.version) + } + + // Use GitHub releases for everything else (curl, yarn, brew tap, unknown) + return fetch("https://api.github.com/repos/sst/opencode/releases/latest") .then((res) => { if (!res.ok) throw new Error(res.statusText) return res.json() }) - .then((data: any) => data.version) + .then((data: any) => data.tag_name.replace(/^v/, "")) } } From 2e10ffac6b4ffe6ff183a485d847e26641f8a1bc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 11:43:03 -0600 Subject: [PATCH 016/101] chore: rm comments --- packages/opencode/src/installation/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index effe19721b4..975ca749bce 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -167,7 +167,6 @@ export namespace Installation { export async function latest(installMethod?: Method) { const detectedMethod = installMethod || (await method()) - // Use brew formula API for homebrew core formula if (detectedMethod === "brew") { const formula = await getBrewFormula() if (formula === "opencode") { @@ -180,7 +179,6 @@ export namespace Installation { } } - // Use npm registry for npm/bun/pnpm if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { const registry = await iife(async () => { const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() @@ -196,7 +194,6 @@ export namespace Installation { .then((data: any) => data.version) } - // Use GitHub releases for everything else (curl, yarn, brew tap, unknown) return fetch("https://api.github.com/repos/sst/opencode/releases/latest") .then((res) => { if (!res.ok) throw new Error(res.statusText) From 505068d5a6cccc732aed76580f9bec6a5dbca507 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 11:43:52 -0600 Subject: [PATCH 017/101] Revert "feat(core): optional mdns service (#6192)" This reverts commit 26e7043718fbf5fbf08eecd04ff8ed5edd82e33e. --- bun.lock | 11 ---- flake.lock | 6 +-- packages/opencode/package.json | 1 - packages/opencode/src/cli/cmd/acp.ts | 30 +++++++---- packages/opencode/src/cli/cmd/serve.ts | 25 ++++++--- packages/opencode/src/cli/cmd/tui/spawn.ts | 28 ++++++---- packages/opencode/src/cli/cmd/tui/thread.ts | 22 +++++--- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 31 +++++++---- packages/opencode/src/cli/network.ts | 42 --------------- packages/opencode/src/config/config.ts | 12 ----- packages/opencode/src/server/mdns.ts | 57 --------------------- packages/opencode/src/server/server.ts | 33 ++---------- packages/web/src/content/docs/cli.mdx | 18 +++---- packages/web/src/content/docs/config.mdx | 25 --------- packages/web/src/content/docs/server.mdx | 9 ++-- 16 files changed, 115 insertions(+), 237 deletions(-) delete mode 100644 packages/opencode/src/cli/network.ts delete mode 100644 packages/opencode/src/server/mdns.ts diff --git a/bun.lock b/bun.lock index 593033e34f4..d2fd6aa8de9 100644 --- a/bun.lock +++ b/bun.lock @@ -292,7 +292,6 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", - "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", @@ -1082,8 +1081,6 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], - "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], - "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -2006,8 +2003,6 @@ "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], - "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -2252,8 +2247,6 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], - "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], - "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -3030,8 +3023,6 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], - "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], @@ -3604,8 +3595,6 @@ "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], - "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], diff --git a/flake.lock b/flake.lock index 8bba6eeb3df..4ff2c1d0e11 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766747458, - "narHash": "sha256-m63jjuo/ygo8ztkCziYh5OOIbTSXUDkKbqw3Vuqu4a4=", + "lastModified": 1766532406, + "narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c633f572eded8c4f3c75b8010129854ed404a6ce", + "rev": "8142186f001295e5a3239f485c8a49bf2de2695a", "type": "github" }, "original": { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 55656660e06..bf45966d86c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -88,7 +88,6 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", - "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 2db64e3b1af..c607e5f5bb7 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,10 +3,8 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" -import { Config } from "@/config/config" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" -import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) @@ -21,17 +19,29 @@ export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return withNetworkOptions(yargs).option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) + return yargs + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + .option("port", { + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) - const server = Server.listen(opts) + const server = Server.listen({ + port: args.port, + hostname: args.hostname, + }) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 0fd7aa88f32..3af3316a9d3 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,16 +1,29 @@ -import { Config } from "../../config/config" import { Server } from "../../server/server" import { cmd } from "./cmd" -import { withNetworkOptions, resolveNetworkOptions } from "../network" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + yargs + .option("port", { + alias: ["p"], + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }), describe: "starts a headless opencode server", handler: async (args) => { - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) - const server = Server.listen(opts) + const hostname = args.hostname + const port = args.port + const server = Server.listen({ + port, + hostname, + }) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) await server.stop() diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index 7ab846428d7..fa679529890 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -1,23 +1,33 @@ import { cmd } from "@/cli/cmd/cmd" -import { Config } from "@/config/config" import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" import { upgrade } from "@/cli/upgrade" -import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" export const TuiSpawnCommand = cmd({ command: "spawn [project]", builder: (yargs) => - withNetworkOptions(yargs).positional("project", { - type: "string", - describe: "path to start opencode in", - }), + yargs + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("port", { + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }), handler: async (args) => { upgrade() - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) - const server = Server.listen(opts) + const server = Server.listen({ + port: args.port, + hostname: "127.0.0.1", + }) const bin = process.execPath const cmd = [] let cwd = process.cwd() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index f75e3bd6511..3cf8937a974 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -6,8 +6,6 @@ import path from "path" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" -import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" -import { Config } from "@/config/config" declare global { const OPENCODE_WORKER_PATH: string @@ -17,7 +15,7 @@ export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - withNetworkOptions(yargs) + yargs .positional("project", { type: "string", describe: "path to start opencode in", @@ -38,12 +36,23 @@ export const TuiThreadCommand = cmd({ describe: "session id to continue", }) .option("prompt", { + alias: ["p"], type: "string", describe: "prompt to use", }) .option("agent", { type: "string", describe: "agent to use", + }) + .option("port", { + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", }), handler: async (args) => { // Resolve relative paths against PWD to preserve behavior when using --cwd flag @@ -78,9 +87,10 @@ export const TuiThreadCommand = cmd({ process.on("unhandledRejection", (e) => { Log.Default.error(e) }) - const config = await Config.get() - const networkOpts = resolveNetworkOptions(args, config) - const server = await client.call("server", networkOpts) + const server = await client.call("server", { + port: args.port, + hostname: args.hostname, + }) const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 3ffc45ae884..76f78f3faa8 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -30,7 +30,7 @@ process.on("uncaughtException", (e) => { let server: Bun.Server export const rpc = { - async server(input: { port: number; hostname: string; mdns?: boolean }) { + async server(input: { port: number; hostname: string }) { if (server) await server.stop(true) try { server = Server.listen(input) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index adede03859c..3d3036b1b07 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,8 +1,6 @@ -import { Config } from "../../config/config" import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" -import { withNetworkOptions, resolveNetworkOptions } from "../network" import open from "open" import { networkInterfaces } from "os" @@ -30,17 +28,32 @@ function getNetworkIPs() { export const WebCommand = cmd({ command: "web", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + yargs + .option("port", { + alias: ["p"], + type: "number", + describe: "port to listen on", + default: 0, + }) + .option("hostname", { + type: "string", + describe: "hostname to listen on", + default: "127.0.0.1", + }), describe: "starts a headless opencode server", handler: async (args) => { - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) - const server = Server.listen(opts) + const hostname = args.hostname + const port = args.port + const server = Server.listen({ + port, + hostname, + }) UI.empty() UI.println(UI.logo(" ")) UI.empty() - if (opts.hostname === "0.0.0.0") { + if (hostname === "0.0.0.0") { // Show localhost for local access const localhostUrl = `http://localhost:${server.port}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) @@ -57,10 +70,6 @@ export const WebCommand = cmd({ } } - if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") - } - // Open localhost in browser open(localhostUrl.toString()).catch(() => {}) } else { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts deleted file mode 100644 index 661688572bd..00000000000 --- a/packages/opencode/src/cli/network.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Argv, InferredOptionTypes } from "yargs" -import type { Config } from "../config/config" - -const options = { - port: { - type: "number" as const, - describe: "port to listen on", - default: 0, - }, - hostname: { - type: "string" as const, - describe: "hostname to listen on", - default: "127.0.0.1", - }, - mdns: { - type: "boolean" as const, - describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", - default: false, - }, -} - -export type NetworkOptions = InferredOptionTypes - -export function withNetworkOptions(yargs: Argv) { - return yargs.options(options) -} - -export function resolveNetworkOptions(args: NetworkOptions, config?: Config.Info) { - const portExplicitlySet = process.argv.includes("--port") - const hostnameExplicitlySet = process.argv.includes("--hostname") - const mdnsExplicitlySet = process.argv.includes("--mdns") - - const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) - const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) - const hostname = hostnameExplicitlySet - ? args.hostname - : mdns && !config?.server?.hostname - ? "0.0.0.0" - : (config?.server?.hostname ?? args.hostname) - - return { hostname, port, mdns } -} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3350cf8f6a6..802c5db9296 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -587,17 +587,6 @@ export namespace Config { .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), }) - export const Server = z - .object({ - port: z.number().int().positive().optional().describe("Port to listen on"), - hostname: z.string().optional().describe("Hostname to listen on"), - mdns: z.boolean().optional().describe("Enable mDNS service discovery"), - }) - .strict() - .meta({ - ref: "ServerConfig", - }) - export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) @@ -646,7 +635,6 @@ export namespace Config { keybinds: Keybinds.optional().describe("Custom keybind configurations"), logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), - server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z .record(z.string(), Command) .optional() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts deleted file mode 100644 index 45e61d361ac..00000000000 --- a/packages/opencode/src/server/mdns.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Log } from "@/util/log" -import Bonjour from "bonjour-service" - -const log = Log.create({ service: "mdns" }) - -export namespace MDNS { - let bonjour: Bonjour | undefined - let currentPort: number | undefined - - export function publish(port: number, name = "opencode") { - if (currentPort === port) return - if (bonjour) unpublish() - - try { - bonjour = new Bonjour() - const service = bonjour.publish({ - name, - type: "http", - port, - txt: { path: "/" }, - }) - - service.on("up", () => { - log.info("mDNS service published", { name, port }) - }) - - service.on("error", (err) => { - log.error("mDNS service error", { error: err }) - }) - - currentPort = port - } catch (err) { - log.error("mDNS publish failed", { error: err }) - if (bonjour) { - try { - bonjour.destroy() - } catch {} - } - bonjour = undefined - currentPort = undefined - } - } - - export function unpublish() { - if (bonjour) { - try { - bonjour.unpublishAll() - bonjour.destroy() - } catch (err) { - log.error("mDNS unpublish failed", { error: err }) - } - bonjour = undefined - currentPort = undefined - log.info("mDNS service unpublished") - } - } -} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 65393e12897..c74dbbb41ef 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,11 +45,9 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket } from "hono/bun" -import type { BunWebSocketData } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" import { Installation } from "@/installation" -import { MDNS } from "./mdns" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -2625,41 +2623,20 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string; mdns?: boolean }) { + export function listen(opts: { port: number; hostname: string }) { const args = { hostname: opts.hostname, idleTimeout: 0, fetch: App().fetch, websocket: websocket, } as const - const tryServe = (port: number) => { + if (opts.port === 0) { try { - return Bun.serve({ ...args, port }) + return Bun.serve({ ...args, port: 4096 }) } catch { - return undefined + // port 4096 not available, fall through to use port 0 } } - const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) - if (!server) throw new Error(`Failed to start server on port ${opts.port}`) - - const shouldPublishMDNS = - opts.mdns && - server.port && - opts.hostname !== "127.0.0.1" && - opts.hostname !== "localhost" && - opts.hostname !== "::1" - if (shouldPublishMDNS) { - MDNS.publish(server.port!) - } else if (opts.mdns) { - log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") - } - - const originalStop = server.stop.bind(server) - server.stop = async (closeActiveConnections?: boolean) => { - if (shouldPublishMDNS) MDNS.unpublish() - return originalStop(closeActiveConnections) - } - - return server + return Bun.serve({ ...args, port: opts.port }) } } diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4a826e5b3ff..e4e40ac7a4c 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -335,11 +335,10 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Description | -| ------------ | --------------------- | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | +| Flag | Short | Description | +| ------------ | ----- | --------------------- | +| `--port` | `-p` | Port to listen on | +| `--hostname` | | Hostname to listen on | --- @@ -429,11 +428,10 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Description | -| ------------ | --------------------- | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | +| Flag | Short | Description | +| ------------ | ----- | --------------------- | +| `--port` | `-p` | Port to listen on | +| `--hostname` | | Hostname to listen on | --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index d7f8031782c..ebaff36bb15 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -120,31 +120,6 @@ Available options: --- -### Server - -You can configure server settings for the `opencode serve` and `opencode web` commands through the `server` option. - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "server": { - "port": 4096, - "hostname": "0.0.0.0", - "mdns": true - } -} -``` - -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. - -[Learn more about the server here](/docs/server). - ---- - ### Tools You can manage the tools an LLM can use through the `tools` option. diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index c63917f792e..427d8f505ff 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -18,11 +18,10 @@ opencode serve [--port ] [--hostname ] #### Options -| Flag | Description | Default | -| ------------ | --------------------- | ----------- | -| `--port` | Port to listen on | `4096` | -| `--hostname` | Hostname to listen on | `127.0.0.1` | -| `--mdns` | Enable mDNS discovery | `false` | +| Flag | Short | Description | Default | +| ------------ | ----- | --------------------- | ----------- | +| `--port` | `-p` | Port to listen on | `4096` | +| `--hostname` | `-h` | Hostname to listen on | `127.0.0.1` | --- From f4fdf0eb0390db7f15ca9ce2986c831d59d234db Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 26 Dec 2025 17:45:03 +0000 Subject: [PATCH 018/101] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 19 ------------------- packages/sdk/openapi.json | 24 ------------------------ 2 files changed, 43 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6b5cfc818d0..b1d05813ce3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1156,24 +1156,6 @@ export type KeybindsConfig = { */ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" -/** - * Server configuration for opencode serve and web commands - */ -export type ServerConfig = { - /** - * Port to listen on - */ - port?: number - /** - * Hostname to listen on - */ - hostname?: string - /** - * Enable mDNS service discovery - */ - mdns?: boolean -} - export type AgentConfig = { model?: string temperature?: number @@ -1434,7 +1416,6 @@ export type Config = { */ diff_style?: "auto" | "stacked" } - server?: ServerConfig /** * Command configuration, see https://opencode.ai/docs/commands */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2a2984854f8..ef6ee0ebfa0 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7692,27 +7692,6 @@ "type": "string", "enum": ["DEBUG", "INFO", "WARN", "ERROR"] }, - "ServerConfig": { - "description": "Server configuration for opencode serve and web commands", - "type": "object", - "properties": { - "port": { - "description": "Port to listen on", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "hostname": { - "description": "Hostname to listen on", - "type": "string" - }, - "mdns": { - "description": "Enable mDNS service discovery", - "type": "boolean" - } - }, - "additionalProperties": false - }, "AgentConfig": { "type": "object", "properties": { @@ -8199,9 +8178,6 @@ } } }, - "server": { - "$ref": "#/components/schemas/ServerConfig" - }, "command": { "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", From 9afc45102014df2aa258f55b74d48987594bc2f7 Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 26 Dec 2025 17:45:58 +0000 Subject: [PATCH 019/101] Update Nix flake.lock and hashes --- flake.lock | 6 +++--- nix/hashes.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 4ff2c1d0e11..8bba6eeb3df 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1766532406, - "narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", + "lastModified": 1766747458, + "narHash": "sha256-m63jjuo/ygo8ztkCziYh5OOIbTSXUDkKbqw3Vuqu4a4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8142186f001295e5a3239f485c8a49bf2de2695a", + "rev": "c633f572eded8c4f3c75b8010129854ed404a6ce", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index f43b14684c3..9ef78c2321d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-lloUZt5mLyNWAcbQrJB4wGUKvKu24WFEhOLfZD5/FMg=" + "nodeModules": "sha256-CTW7pzZ0Kq5HHF5xgEh3EnuwnqtPsDkc5ImmZjJgwA8=" } From 7a5fbdf67c67cf6cd7cc84da066bcb34871ca609 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 12:06:36 -0600 Subject: [PATCH 020/101] ci: update zed extension sync --- .github/workflows/sync-zed-extension.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index a504582c3c8..900a16418f3 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -31,4 +31,4 @@ jobs: run: | ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }} env: - GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.ZED_EXTENSIONS_PAT }} From e1c1b1340b4c0c455f7701311118cf32924e9abd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 12:08:16 -0600 Subject: [PATCH 021/101] ci: fix var --- .github/workflows/sync-zed-extension.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index 900a16418f3..047cf001612 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -31,4 +31,4 @@ jobs: run: | ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }} env: - GITHUB_TOKEN: ${{ secrets.ZED_EXTENSIONS_PAT }} + ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }} From 053a10e5155bb6a18765aceddc70599a6359e6c1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 12:22:56 -0600 Subject: [PATCH 022/101] ci: fix token for gh --- script/sync-zed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/sync-zed.ts b/script/sync-zed.ts index b4a417ad8b9..3ac9ee83a7e 100755 --- a/script/sync-zed.ts +++ b/script/sync-zed.ts @@ -107,7 +107,7 @@ async function main() { console.log(`📬 Creating pull request...`) const prUrl = - await $`gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text() + await $`GH_TOKEN=${token} gh pr create --repo ${UPSTREAM_REPO} --base main --head ${FORK_REPO.split("/")[0]}:${branchName} --title "Update ${EXTENSION_NAME} to v${cleanVersion}" --body "Updating OpenCode extension to v${cleanVersion}"`.text() console.log(`✅ Pull request created: ${prUrl}`) console.log(`🎉 Done!`) From 61ddd1716d86b0be060b70c5333ca32909c5e922 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 12:24:14 -0600 Subject: [PATCH 023/101] ci: re-enable sync zed --- .github/workflows/sync-zed-extension.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-zed-extension.yml b/.github/workflows/sync-zed-extension.yml index 047cf001612..9e647b8d941 100644 --- a/.github/workflows/sync-zed-extension.yml +++ b/.github/workflows/sync-zed-extension.yml @@ -2,8 +2,8 @@ name: "sync-zed-extension" on: workflow_dispatch: - # release: - # types: [published] + release: + types: [published] jobs: zed: From 1626341a4a7ce4e390c5d45d804ac02c928ca5fc Mon Sep 17 00:00:00 2001 From: Matt Silverlock Date: Fri, 26 Dec 2025 14:34:03 -0500 Subject: [PATCH 024/101] github: support issues and workflow_dispatch events (#6157) --- packages/opencode/src/cli/cmd/github.ts | 88 ++++++++++++++++-------- packages/web/src/content/docs/github.mdx | 71 ++++++++++++++++--- 2 files changed, 123 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 37aed2426d1..748a9638490 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -9,7 +9,9 @@ import * as github from "@actions/github" import type { Context } from "@actions/github/lib/context" import type { IssueCommentEvent, + IssuesEvent, PullRequestReviewCommentEvent, + WorkflowDispatchEvent, WorkflowRunEvent, PullRequestEvent, } from "@octokit/webhooks-types" @@ -132,7 +134,16 @@ type IssueQueryResponse = { const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" const WORKFLOW_FILE = ".github/workflows/opencode.yml" -const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const + +// Event categories for routing +// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments +// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only +const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const +const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const +const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const + +type UserEvent = (typeof USER_EVENTS)[number] +type RepoEvent = (typeof REPO_EVENTS)[number] // Parses GitHub remote URLs in various formats: // - https://github.com/owner/repo.git @@ -397,27 +408,38 @@ export const GithubRunCommand = cmd({ core.setFailed(`Unsupported event type: ${context.eventName}`) process.exit(1) } + + // Determine event category for routing + // USER_EVENTS: have actor, issueId, support reactions/comments + // REPO_EVENTS: no actor/issueId, output to logs/PR only + const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent) + const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent) const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName) + const isIssuesEvent = context.eventName === "issues" const isScheduleEvent = context.eventName === "schedule" + const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch" const { providerID, modelID } = normalizeModel() const runId = normalizeRunId() const share = normalizeShare() const oidcBaseUrl = normalizeOidcBaseUrl() const { owner, repo } = context.repo - // For schedule events, payload has no issue/comment data + // For repo events (schedule, workflow_dispatch), payload has no issue/comment data const payload = context.payload as | IssueCommentEvent + | IssuesEvent | PullRequestReviewCommentEvent + | WorkflowDispatchEvent | WorkflowRunEvent | PullRequestEvent const issueEvent = isIssueCommentEvent(payload) ? payload : undefined + // workflow_dispatch has an actor (the user who triggered it), schedule does not const actor = isScheduleEvent ? undefined : context.actor - const issueId = isScheduleEvent + const issueId = isRepoEvent ? undefined - : context.eventName === "issue_comment" - ? (payload as IssueCommentEvent).issue.number + : context.eventName === "issue_comment" || context.eventName === "issues" + ? (payload as IssueCommentEvent | IssuesEvent).issue.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" @@ -462,8 +484,8 @@ export const GithubRunCommand = cmd({ if (!useGithubToken) { await configureGit(appToken) } - // Skip permission check for schedule events (no actor to check) - if (!isScheduleEvent) { + // Skip permission check and reactions for repo events (no actor to check, no issue to react to) + if (isUserEvent) { await assertPermissions() await addReaction(commentType) } @@ -480,25 +502,30 @@ export const GithubRunCommand = cmd({ })() console.log("opencode session", session.id) - // Handle 4 cases - // 1. Schedule (no issue/PR context) - // 2. Issue - // 3. Local PR - // 4. Fork PR - if (isScheduleEvent) { - // Schedule event - no issue/PR context, output goes to logs - const branch = await checkoutNewBranch("schedule") + // Handle event types: + // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only + // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch + // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR + if (isRepoEvent) { + // Repo event - no issue/PR context, output goes to logs + if (isWorkflowDispatchEvent && actor) { + console.log(`Triggered by: ${actor}`) + } + const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule" + const branch = await checkoutNewBranch(branchPrefix) const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const response = await chat(userPrompt, promptFiles) const { dirty, uncommittedChanges } = await branchIsDirty(head) if (dirty) { const summary = await summarize(response) - await pushToNewBranch(summary, branch, uncommittedChanges, true) + // workflow_dispatch has an actor for co-author attribution, schedule does not + await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent) + const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow" const pr = await createPR( repoData.data.default_branch, branch, summary, - `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`, + `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`, ) console.log(`Created PR #${pr}`) } else { @@ -573,7 +600,7 @@ export const GithubRunCommand = cmd({ } else if (e instanceof Error) { msg = e.message } - if (!isScheduleEvent) { + if (isUserEvent) { await createComment(`${msg}${footer()}`) await removeReaction(commentType) } @@ -628,9 +655,15 @@ export const GithubRunCommand = cmd({ } function isIssueCommentEvent( - event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent, + event: + | IssueCommentEvent + | IssuesEvent + | PullRequestReviewCommentEvent + | WorkflowDispatchEvent + | WorkflowRunEvent + | PullRequestEvent, ): event is IssueCommentEvent { - return "issue" in event + return "issue" in event && "comment" in event } function getReviewCommentContext() { @@ -652,10 +685,11 @@ export const GithubRunCommand = cmd({ async function getUserPrompt() { const customPrompt = process.env["PROMPT"] - // For schedule events, PROMPT is required since there's no comment to extract from - if (isScheduleEvent) { + // For repo events and issues events, PROMPT is required since there's no comment to extract from + if (isRepoEvent || isIssuesEvent) { if (!customPrompt) { - throw new Error("PROMPT input is required for scheduled events") + const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues" + throw new Error(`PROMPT input is required for ${eventType} events`) } return { userPrompt: customPrompt, promptFiles: [] } } @@ -923,7 +957,7 @@ export const GithubRunCommand = cmd({ await $`git config --local ${config} "${gitConfig}"` } - async function checkoutNewBranch(type: "issue" | "schedule") { + async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") { console.log("Checking out new branch...") const branch = generateBranchName(type) await $`git checkout -b ${branch}` @@ -952,16 +986,16 @@ export const GithubRunCommand = cmd({ await $`git checkout -b ${localBranch} fork/${remoteBranch}` } - function generateBranchName(type: "issue" | "pr" | "schedule") { + function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") { const timestamp = new Date() .toISOString() .replace(/[:-]/g, "") .replace(/\.\d{3}Z/, "") .split("T") .join("") - if (type === "schedule") { + if (type === "schedule" || type === "dispatch") { const hex = crypto.randomUUID().slice(0, 6) - return `opencode/scheduled-${hex}-${timestamp}` + return `opencode/${type}-${hex}-${timestamp}` } return `opencode/${type}${issueId}-${timestamp}` } diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 25c3ce927a1..63c5d855b9c 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -104,12 +104,14 @@ Or you can set it up manually. OpenCode can be triggered by the following GitHub events: -| Event Type | Triggered By | Details | -| ----------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads the issue/PR context and can create branches, open PRs, or reply with explanations. | -| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context for precise responses. | -| `schedule` | Cron-based schedule | Run OpenCode on a schedule using the `prompt` input. Useful for automated code reviews, reports, or maintenance tasks. OpenCode can create issues or PRs as needed. | -| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews without needing to leave a comment. | +| Event Type | Triggered By | Details | +| ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `issue_comment` | Comment on an issue or PR | Mention `/opencode` or `/oc` in your comment. OpenCode reads context and can create branches, open PRs, or reply. | +| `pull_request_review_comment` | Comment on specific code lines in a PR | Mention `/opencode` or `/oc` while reviewing code. OpenCode receives file path, line numbers, and diff context. | +| `issues` | Issue opened or edited | Automatically trigger OpenCode when issues are created or modified. Requires `prompt` input. | +| `pull_request` | PR opened or updated | Automatically trigger OpenCode when PRs are opened, synchronized, or reopened. Useful for automated reviews. | +| `schedule` | Cron-based schedule | Run OpenCode on a schedule. Requires `prompt` input. Output goes to logs and PRs (no issue to comment on). | +| `workflow_dispatch` | Manual trigger from GitHub UI | Trigger OpenCode on demand via Actions tab. Requires `prompt` input. Output goes to logs and PRs. | ### Schedule Example @@ -145,9 +147,7 @@ jobs: If you find issues worth addressing, open an issue to track them. ``` -For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. - -> **Note:** Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs during a scheduled run. +For scheduled events, the `prompt` input is **required** since there's no comment to extract instructions from. Scheduled workflows run without a user context to permission-check, so the workflow must grant `contents: write` and `pull-requests: write` if you expect OpenCode to create branches or PRs. --- @@ -188,6 +188,59 @@ For `pull_request` events, if no `prompt` is provided, OpenCode defaults to revi --- +### Issues Triage Example + +Automatically triage new issues. This example filters to accounts older than 30 days to reduce spam: + +```yaml title=".github/workflows/opencode-triage.yml" +name: Issue Triage + +on: + issues: + types: [opened] + +jobs: + triage: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + issues: write + steps: + - name: Check account age + id: check + uses: actions/github-script@v7 + with: + script: | + const user = await github.rest.users.getByUsername({ + username: context.payload.issue.user.login + }); + const created = new Date(user.data.created_at); + const days = (Date.now() - created) / (1000 * 60 * 60 * 24); + return days >= 30; + result-encoding: string + + - uses: actions/checkout@v4 + if: steps.check.outputs.result == 'true' + + - uses: sst/opencode/github@latest + if: steps.check.outputs.result == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + prompt: | + Review this issue. If there's a clear fix or relevant docs: + - Provide documentation links + - Add error handling guidance for code examples + Otherwise, do not comment. +``` + +For `issues` events, the `prompt` input is **required** since there's no comment to extract instructions from. + +--- + ## Custom prompts Override the default prompt to customize OpenCode's behavior for your workflow. From 160c8ab7cc5f91be3bc74e775f81f6eaa2d74dcc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 13:44:52 -0600 Subject: [PATCH 025/101] tweak: bash tool description to avoid unnecessary 'cd &&' usage --- packages/opencode/src/tool/bash.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index a81deb62bf2..18ee14012b2 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,6 +1,6 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. -All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. +All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. @@ -11,10 +11,10 @@ Before executing the command, please follow these steps: - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory 2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - Examples of proper quoting: - - cd "/Users/name/My Documents" (correct) - - cd /Users/name/My Documents (incorrect - will fail) + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) - python "/path/with spaces/script.py" (correct) - python /path/with spaces/script.py (incorrect - will fail) - After ensuring proper quoting, execute the command. @@ -26,7 +26,7 @@ Usage notes: - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. - + - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - File search: Use Glob (NOT find or ls) - Content search: Use Grep (NOT grep or rg) @@ -39,9 +39,9 @@ Usage notes: - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. - Use ';' only when you need to run commands sequentially but don't care if earlier commands fail - DO NOT use newlines to separate commands (newlines are ok in quoted strings) - - Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it. + - AVOID using `cd && `. Use the `workdir` parameter to change directories instead. - pytest /foo/bar/tests + Use workdir="/foo/bar" with command: pytest tests cd /foo/bar && pytest tests @@ -53,7 +53,7 @@ Only create commits when requested by the user. If unclear, ask first. When the Git Safety Protocol: - NEVER update the git config -- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them +- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them - NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it - NEVER run force push to main/master, warn the user if they request it - Avoid git commit --amend. ONLY use --amend when ALL conditions are met: From 664e6bf2d06fee041ec71ac8af84b031c5333ea1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 14:30:05 -0600 Subject: [PATCH 026/101] test: add more tests to make sure that cwd is locked for read tool --- packages/opencode/test/tool/read.test.ts | 131 +++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 47a7aee2ae6..eb860d04fcc 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,6 +13,137 @@ const ctx = { metadata: () => {}, } +describe("tool.read external_directory permission", () => { + test("allows reading absolute path inside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "hello world") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "test.txt") }, ctx) + expect(result.output).toContain("hello world") + }, + }) + }) + + test("allows reading file in subdirectory inside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "test.txt"), "nested content") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "test.txt") }, ctx) + expect(result.output).toContain("nested content") + }, + }) + }) + + test("denies reading absolute path outside project directory", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "secret.txt"), "secret data") + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + await expect(read.execute({ filePath: path.join(outerTmp.path, "secret.txt") }, ctx)).rejects.toThrow( + "not in the current working directory", + ) + }, + }) + }) + + test("denies reading relative path that traverses outside project directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + await expect(read.execute({ filePath: "../../../etc/passwd" }, ctx)).rejects.toThrow( + "not in the current working directory", + ) + }, + }) + }) + + test("allows reading outside project directory when external_directory is allow", async () => { + await using outerTmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "external.txt"), "external content") + }, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "allow", + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(outerTmp.path, "external.txt") }, ctx) + expect(result.output).toContain("external content") + }, + }) + }) +}) + describe("tool.read env file blocking", () => { test.each([ [".env", true], From 1e2ef07c9789b8f156cc8ad366111149b8fba255 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 26 Dec 2025 14:31:22 -0600 Subject: [PATCH 027/101] chore: kill some unused tools --- packages/opencode/src/tool/lsp-diagnostics.ts | 26 ---------------- .../opencode/src/tool/lsp-diagnostics.txt | 1 - packages/opencode/src/tool/lsp-hover.ts | 31 ------------------- packages/opencode/src/tool/lsp-hover.txt | 1 - 4 files changed, 59 deletions(-) delete mode 100644 packages/opencode/src/tool/lsp-diagnostics.ts delete mode 100644 packages/opencode/src/tool/lsp-diagnostics.txt delete mode 100644 packages/opencode/src/tool/lsp-hover.ts delete mode 100644 packages/opencode/src/tool/lsp-hover.txt diff --git a/packages/opencode/src/tool/lsp-diagnostics.ts b/packages/opencode/src/tool/lsp-diagnostics.ts deleted file mode 100644 index 18a6868b677..00000000000 --- a/packages/opencode/src/tool/lsp-diagnostics.ts +++ /dev/null @@ -1,26 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import path from "path" -import { LSP } from "../lsp" -import DESCRIPTION from "./lsp-diagnostics.txt" -import { Instance } from "../project/instance" - -export const LspDiagnosticTool = Tool.define("lsp_diagnostics", { - description: DESCRIPTION, - parameters: z.object({ - path: z.string().describe("The path to the file to get diagnostics."), - }), - execute: async (args) => { - const normalized = path.isAbsolute(args.path) ? args.path : path.join(Instance.directory, args.path) - await LSP.touchFile(normalized, true) - const diagnostics = await LSP.diagnostics() - const file = diagnostics[normalized] - return { - title: path.relative(Instance.worktree, normalized), - metadata: { - diagnostics, - }, - output: file?.length ? file.map(LSP.Diagnostic.pretty).join("\n") : "No errors found", - } - }, -}) diff --git a/packages/opencode/src/tool/lsp-diagnostics.txt b/packages/opencode/src/tool/lsp-diagnostics.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/lsp-diagnostics.txt +++ /dev/null @@ -1 +0,0 @@ -do not use diff --git a/packages/opencode/src/tool/lsp-hover.ts b/packages/opencode/src/tool/lsp-hover.ts deleted file mode 100644 index 7ef856cc567..00000000000 --- a/packages/opencode/src/tool/lsp-hover.ts +++ /dev/null @@ -1,31 +0,0 @@ -import z from "zod" -import { Tool } from "./tool" -import path from "path" -import { LSP } from "../lsp" -import DESCRIPTION from "./lsp-hover.txt" -import { Instance } from "../project/instance" - -export const LspHoverTool = Tool.define("lsp_hover", { - description: DESCRIPTION, - parameters: z.object({ - file: z.string().describe("The path to the file to get diagnostics."), - line: z.number().describe("The line number to get diagnostics."), - character: z.number().describe("The character number to get diagnostics."), - }), - execute: async (args) => { - const file = path.isAbsolute(args.file) ? args.file : path.join(Instance.directory, args.file) - await LSP.touchFile(file, true) - const result = await LSP.hover({ - ...args, - file, - }) - - return { - title: path.relative(Instance.worktree, file) + ":" + args.line + ":" + args.character, - metadata: { - result, - }, - output: JSON.stringify(result, null, 2), - } - }, -}) diff --git a/packages/opencode/src/tool/lsp-hover.txt b/packages/opencode/src/tool/lsp-hover.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/lsp-hover.txt +++ /dev/null @@ -1 +0,0 @@ -do not use From b2f45d574f26e8c4c4d48a03ef51c3a421a48b50 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:24:44 -0600 Subject: [PATCH 028/101] Reapply "feat(core): optional mdns service (#6192)" This reverts commit 505068d5a6cccc732aed76580f9bec6a5dbca507. --- bun.lock | 11 ++++ packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/acp.ts | 30 ++++------- packages/opencode/src/cli/cmd/serve.ts | 25 +++------ packages/opencode/src/cli/cmd/tui/spawn.ts | 28 ++++------ packages/opencode/src/cli/cmd/tui/thread.ts | 22 +++----- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/cli/cmd/web.ts | 31 ++++------- packages/opencode/src/cli/network.ts | 42 +++++++++++++++ packages/opencode/src/config/config.ts | 12 +++++ packages/opencode/src/server/mdns.ts | 57 +++++++++++++++++++++ packages/opencode/src/server/server.ts | 33 ++++++++++-- packages/web/src/content/docs/cli.mdx | 18 ++++--- packages/web/src/content/docs/config.mdx | 25 +++++++++ packages/web/src/content/docs/server.mdx | 9 ++-- 15 files changed, 234 insertions(+), 112 deletions(-) create mode 100644 packages/opencode/src/cli/network.ts create mode 100644 packages/opencode/src/server/mdns.ts diff --git a/bun.lock b/bun.lock index d2fd6aa8de9..593033e34f4 100644 --- a/bun.lock +++ b/bun.lock @@ -292,6 +292,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", @@ -1081,6 +1082,8 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -2003,6 +2006,8 @@ "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], + "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], @@ -2247,6 +2252,8 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -3023,6 +3030,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], @@ -3595,6 +3604,8 @@ "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index bf45966d86c..55656660e06 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -88,6 +88,7 @@ "@standard-schema/spec": "1.0.0", "@zip.js/zip.js": "2.7.62", "ai": "catalog:", + "bonjour-service": "1.3.0", "bun-pty": "0.4.2", "chokidar": "4.0.3", "clipboardy": "4.0.0", diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index c607e5f5bb7..2db64e3b1af 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,8 +3,10 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" +import { Config } from "@/config/config" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) @@ -19,29 +21,17 @@ export const AcpCommand = cmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return yargs - .option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }) + return withNetworkOptions(yargs).option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const server = Server.listen({ - port: args.port, - hostname: args.hostname, - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3af3316a9d3..0fd7aa88f32 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,29 +1,16 @@ +import { Config } from "../../config/config" import { Server } from "../../server/server" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) await server.stop() diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index fa679529890..7ab846428d7 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -1,33 +1,23 @@ import { cmd } from "@/cli/cmd/cmd" +import { Config } from "@/config/config" import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" import { upgrade } from "@/cli/upgrade" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" export const TuiSpawnCommand = cmd({ command: "spawn [project]", builder: (yargs) => - yargs - .positional("project", { - type: "string", - describe: "path to start opencode in", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + withNetworkOptions(yargs).positional("project", { + type: "string", + describe: "path to start opencode in", + }), handler: async (args) => { upgrade() - const server = Server.listen({ - port: args.port, - hostname: "127.0.0.1", - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) const bin = process.execPath const cmd = [] let cwd = process.cwd() diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 3cf8937a974..f75e3bd6511 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -6,6 +6,8 @@ import path from "path" import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { Config } from "@/config/config" declare global { const OPENCODE_WORKER_PATH: string @@ -15,7 +17,7 @@ export const TuiThreadCommand = cmd({ command: "$0 [project]", describe: "start opencode tui", builder: (yargs) => - yargs + withNetworkOptions(yargs) .positional("project", { type: "string", describe: "path to start opencode in", @@ -36,23 +38,12 @@ export const TuiThreadCommand = cmd({ describe: "session id to continue", }) .option("prompt", { - alias: ["p"], type: "string", describe: "prompt to use", }) .option("agent", { type: "string", describe: "agent to use", - }) - .option("port", { - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", }), handler: async (args) => { // Resolve relative paths against PWD to preserve behavior when using --cwd flag @@ -87,10 +78,9 @@ export const TuiThreadCommand = cmd({ process.on("unhandledRejection", (e) => { Log.Default.error(e) }) - const server = await client.call("server", { - port: args.port, - hostname: args.hostname, - }) + const config = await Config.get() + const networkOpts = resolveNetworkOptions(args, config) + const server = await client.call("server", networkOpts) const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 76f78f3faa8..3ffc45ae884 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -30,7 +30,7 @@ process.on("uncaughtException", (e) => { let server: Bun.Server export const rpc = { - async server(input: { port: number; hostname: string }) { + async server(input: { port: number; hostname: string; mdns?: boolean }) { if (server) await server.stop(true) try { server = Server.listen(input) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 3d3036b1b07..adede03859c 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,8 @@ +import { Config } from "../../config/config" import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" +import { withNetworkOptions, resolveNetworkOptions } from "../network" import open from "open" import { networkInterfaces } from "os" @@ -28,32 +30,17 @@ function getNetworkIPs() { export const WebCommand = cmd({ command: "web", - builder: (yargs) => - yargs - .option("port", { - alias: ["p"], - type: "number", - describe: "port to listen on", - default: 0, - }) - .option("hostname", { - type: "string", - describe: "hostname to listen on", - default: "127.0.0.1", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const hostname = args.hostname - const port = args.port - const server = Server.listen({ - port, - hostname, - }) + const config = await Config.get() + const opts = resolveNetworkOptions(args, config) + const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) UI.empty() - if (hostname === "0.0.0.0") { + if (opts.hostname === "0.0.0.0") { // Show localhost for local access const localhostUrl = `http://localhost:${server.port}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) @@ -70,6 +57,10 @@ export const WebCommand = cmd({ } } + if (opts.mdns) { + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + } + // Open localhost in browser open(localhostUrl.toString()).catch(() => {}) } else { diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts new file mode 100644 index 00000000000..661688572bd --- /dev/null +++ b/packages/opencode/src/cli/network.ts @@ -0,0 +1,42 @@ +import type { Argv, InferredOptionTypes } from "yargs" +import type { Config } from "../config/config" + +const options = { + port: { + type: "number" as const, + describe: "port to listen on", + default: 0, + }, + hostname: { + type: "string" as const, + describe: "hostname to listen on", + default: "127.0.0.1", + }, + mdns: { + type: "boolean" as const, + describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", + default: false, + }, +} + +export type NetworkOptions = InferredOptionTypes + +export function withNetworkOptions(yargs: Argv) { + return yargs.options(options) +} + +export function resolveNetworkOptions(args: NetworkOptions, config?: Config.Info) { + const portExplicitlySet = process.argv.includes("--port") + const hostnameExplicitlySet = process.argv.includes("--hostname") + const mdnsExplicitlySet = process.argv.includes("--mdns") + + const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) + const hostname = hostnameExplicitlySet + ? args.hostname + : mdns && !config?.server?.hostname + ? "0.0.0.0" + : (config?.server?.hostname ?? args.hostname) + + return { hostname, port, mdns } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 802c5db9296..3350cf8f6a6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -587,6 +587,17 @@ export namespace Config { .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), }) + export const Server = z + .object({ + port: z.number().int().positive().optional().describe("Port to listen on"), + hostname: z.string().optional().describe("Hostname to listen on"), + mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + }) + .strict() + .meta({ + ref: "ServerConfig", + }) + export const Layout = z.enum(["auto", "stretch"]).meta({ ref: "LayoutConfig", }) @@ -635,6 +646,7 @@ export namespace Config { keybinds: Keybinds.optional().describe("Custom keybind configurations"), logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), + server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z .record(z.string(), Command) .optional() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts new file mode 100644 index 00000000000..45e61d361ac --- /dev/null +++ b/packages/opencode/src/server/mdns.ts @@ -0,0 +1,57 @@ +import { Log } from "@/util/log" +import Bonjour from "bonjour-service" + +const log = Log.create({ service: "mdns" }) + +export namespace MDNS { + let bonjour: Bonjour | undefined + let currentPort: number | undefined + + export function publish(port: number, name = "opencode") { + if (currentPort === port) return + if (bonjour) unpublish() + + try { + bonjour = new Bonjour() + const service = bonjour.publish({ + name, + type: "http", + port, + txt: { path: "/" }, + }) + + service.on("up", () => { + log.info("mDNS service published", { name, port }) + }) + + service.on("error", (err) => { + log.error("mDNS service error", { error: err }) + }) + + currentPort = port + } catch (err) { + log.error("mDNS publish failed", { error: err }) + if (bonjour) { + try { + bonjour.destroy() + } catch {} + } + bonjour = undefined + currentPort = undefined + } + } + + export function unpublish() { + if (bonjour) { + try { + bonjour.unpublishAll() + bonjour.destroy() + } catch (err) { + log.error("mDNS unpublish failed", { error: err }) + } + bonjour = undefined + currentPort = undefined + log.info("mDNS service unpublished") + } + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c74dbbb41ef..65393e12897 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -45,9 +45,11 @@ import { Snapshot } from "@/snapshot" import { SessionSummary } from "@/session/summary" import { SessionStatus } from "@/session/status" import { upgradeWebSocket, websocket } from "hono/bun" +import type { BunWebSocketData } from "hono/bun" import { errors } from "./error" import { Pty } from "@/pty" import { Installation } from "@/installation" +import { MDNS } from "./mdns" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -2623,20 +2625,41 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string }) { + export function listen(opts: { port: number; hostname: string; mdns?: boolean }) { const args = { hostname: opts.hostname, idleTimeout: 0, fetch: App().fetch, websocket: websocket, } as const - if (opts.port === 0) { + const tryServe = (port: number) => { try { - return Bun.serve({ ...args, port: 4096 }) + return Bun.serve({ ...args, port }) } catch { - // port 4096 not available, fall through to use port 0 + return undefined } } - return Bun.serve({ ...args, port: opts.port }) + const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) + if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + + const shouldPublishMDNS = + opts.mdns && + server.port && + opts.hostname !== "127.0.0.1" && + opts.hostname !== "localhost" && + opts.hostname !== "::1" + if (shouldPublishMDNS) { + MDNS.publish(server.port!) + } else if (opts.mdns) { + log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + + const originalStop = server.stop.bind(server) + server.stop = async (closeActiveConnections?: boolean) => { + if (shouldPublishMDNS) MDNS.unpublish() + return originalStop(closeActiveConnections) + } + + return server } } diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index e4e40ac7a4c..4a826e5b3ff 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -335,10 +335,11 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------- | -| `--port` | `-p` | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Description | +| ------------ | --------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | --- @@ -428,10 +429,11 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------- | -| `--port` | `-p` | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Description | +| ------------ | --------------------- | +| `--port` | Port to listen on | +| `--hostname` | Hostname to listen on | +| `--mdns` | Enable mDNS discovery | --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index ebaff36bb15..d7f8031782c 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -120,6 +120,31 @@ Available options: --- +### Server + +You can configure server settings for the `opencode serve` and `opencode web` commands through the `server` option. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "server": { + "port": 4096, + "hostname": "0.0.0.0", + "mdns": true + } +} +``` + +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. + +[Learn more about the server here](/docs/server). + +--- + ### Tools You can manage the tools an LLM can use through the `tools` option. diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 427d8f505ff..c63917f792e 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -18,10 +18,11 @@ opencode serve [--port ] [--hostname ] #### Options -| Flag | Short | Description | Default | -| ------------ | ----- | --------------------- | ----------- | -| `--port` | `-p` | Port to listen on | `4096` | -| `--hostname` | `-h` | Hostname to listen on | `127.0.0.1` | +| Flag | Description | Default | +| ------------ | --------------------- | ----------- | +| `--port` | Port to listen on | `4096` | +| `--hostname` | Hostname to listen on | `127.0.0.1` | +| `--mdns` | Enable mDNS discovery | `false` | --- From 390b0a79b395d6b44649c94087db2bbc8937a2f2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 26 Dec 2025 14:47:44 -0600 Subject: [PATCH 029/101] fix(core): mdns global config --- packages/opencode/src/cli/cmd/acp.ts | 4 +--- packages/opencode/src/cli/cmd/serve.ts | 4 +--- packages/opencode/src/cli/cmd/tui/spawn.ts | 4 +--- packages/opencode/src/cli/cmd/tui/thread.ts | 6 ++---- packages/opencode/src/cli/cmd/web.ts | 4 +--- packages/opencode/src/cli/network.ts | 5 +++-- 6 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 2db64e3b1af..060d0d5a156 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -3,7 +3,6 @@ import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" -import { Config } from "@/config/config" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" @@ -29,8 +28,7 @@ export const AcpCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) + const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) const sdk = createOpencodeClient({ diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 0fd7aa88f32..657f9196c96 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,4 +1,3 @@ -import { Config } from "../../config/config" import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" @@ -8,8 +7,7 @@ export const ServeCommand = cmd({ builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) + const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) diff --git a/packages/opencode/src/cli/cmd/tui/spawn.ts b/packages/opencode/src/cli/cmd/tui/spawn.ts index 7ab846428d7..ef359e6f40e 100644 --- a/packages/opencode/src/cli/cmd/tui/spawn.ts +++ b/packages/opencode/src/cli/cmd/tui/spawn.ts @@ -1,5 +1,4 @@ import { cmd } from "@/cli/cmd/cmd" -import { Config } from "@/config/config" import { Instance } from "@/project/instance" import path from "path" import { Server } from "@/server/server" @@ -15,8 +14,7 @@ export const TuiSpawnCommand = cmd({ }), handler: async (args) => { upgrade() - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) + const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) const bin = process.execPath const cmd = [] diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index f75e3bd6511..280f40fb90b 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -7,7 +7,6 @@ import { UI } from "@/cli/ui" import { iife } from "@/util/iife" import { Log } from "@/util/log" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" -import { Config } from "@/config/config" declare global { const OPENCODE_WORKER_PATH: string @@ -78,9 +77,8 @@ export const TuiThreadCommand = cmd({ process.on("unhandledRejection", (e) => { Log.Default.error(e) }) - const config = await Config.get() - const networkOpts = resolveNetworkOptions(args, config) - const server = await client.call("server", networkOpts) + const opts = await resolveNetworkOptions(args) + const server = await client.call("server", opts) const prompt = await iife(async () => { const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined if (!args.prompt) return piped diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index adede03859c..fb32472d7ab 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,4 +1,3 @@ -import { Config } from "../../config/config" import { Server } from "../../server/server" import { UI } from "../ui" import { cmd } from "./cmd" @@ -33,8 +32,7 @@ export const WebCommand = cmd({ builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - const config = await Config.get() - const opts = resolveNetworkOptions(args, config) + const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) UI.empty() UI.println(UI.logo(" ")) diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 661688572bd..397f2ba3e20 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,5 +1,5 @@ import type { Argv, InferredOptionTypes } from "yargs" -import type { Config } from "../config/config" +import { Config } from "../config/config" const options = { port: { @@ -25,7 +25,8 @@ export function withNetworkOptions(yargs: Argv) { return yargs.options(options) } -export function resolveNetworkOptions(args: NetworkOptions, config?: Config.Info) { +export async function resolveNetworkOptions(args: NetworkOptions) { + const config = await Config.global() const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") From da394439a19e03e06491395a3e178cb47a9537c6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 26 Dec 2025 20:48:30 +0000 Subject: [PATCH 030/101] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 19 +++++++++++++++++++ packages/sdk/openapi.json | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b1d05813ce3..6b5cfc818d0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1156,6 +1156,24 @@ export type KeybindsConfig = { */ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" +/** + * Server configuration for opencode serve and web commands + */ +export type ServerConfig = { + /** + * Port to listen on + */ + port?: number + /** + * Hostname to listen on + */ + hostname?: string + /** + * Enable mDNS service discovery + */ + mdns?: boolean +} + export type AgentConfig = { model?: string temperature?: number @@ -1416,6 +1434,7 @@ export type Config = { */ diff_style?: "auto" | "stacked" } + server?: ServerConfig /** * Command configuration, see https://opencode.ai/docs/commands */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ef6ee0ebfa0..2a2984854f8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7692,6 +7692,27 @@ "type": "string", "enum": ["DEBUG", "INFO", "WARN", "ERROR"] }, + "ServerConfig": { + "description": "Server configuration for opencode serve and web commands", + "type": "object", + "properties": { + "port": { + "description": "Port to listen on", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "hostname": { + "description": "Hostname to listen on", + "type": "string" + }, + "mdns": { + "description": "Enable mDNS service discovery", + "type": "boolean" + } + }, + "additionalProperties": false + }, "AgentConfig": { "type": "object", "properties": { @@ -8178,6 +8199,9 @@ } } }, + "server": { + "$ref": "#/components/schemas/ServerConfig" + }, "command": { "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", From a15397cd89881c36c19b3f909ebceddb47aeca90 Mon Sep 17 00:00:00 2001 From: Github Action Date: Fri, 26 Dec 2025 20:49:05 +0000 Subject: [PATCH 031/101] Update Nix flake.lock and hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 9ef78c2321d..f43b14684c3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-CTW7pzZ0Kq5HHF5xgEh3EnuwnqtPsDkc5ImmZjJgwA8=" + "nodeModules": "sha256-lloUZt5mLyNWAcbQrJB4wGUKvKu24WFEhOLfZD5/FMg=" } From 52b99622ad0c01d11a5f8a5826ed955f6aa13dca Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 26 Dec 2025 17:32:37 -0500 Subject: [PATCH 032/101] zen: add context for login errors --- .../console/app/src/routes/auth/callback.ts | 54 +++++++++++-------- packages/console/function/src/auth.ts | 6 ++- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts index a793b85962a..2f8781e9882 100644 --- a/packages/console/app/src/routes/auth/callback.ts +++ b/packages/console/app/src/routes/auth/callback.ts @@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session" export async function GET(input: APIEvent) { const url = new URL(input.request.url) - const code = url.searchParams.get("code") - if (!code) throw new Error("No code found") - const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) - if (result.err) { - throw new Error(result.err.message) - } - const decoded = AuthClient.decode(result.tokens.access, {} as any) - if (decoded.err) throw new Error(decoded.err.message) - const session = await useAuthSession() - const id = decoded.subject.properties.accountID - await session.update((value) => { - return { - ...value, - account: { - ...value.account, - [id]: { - id, - email: decoded.subject.properties.email, + try { + const code = url.searchParams.get("code") + if (!code) throw new Error("No code found") + const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) + if (result.err) throw new Error(result.err.message) + const decoded = AuthClient.decode(result.tokens.access, {} as any) + if (decoded.err) throw new Error(decoded.err.message) + const session = await useAuthSession() + const id = decoded.subject.properties.accountID + await session.update((value) => { + return { + ...value, + account: { + ...value.account, + [id]: { + id, + email: decoded.subject.properties.email, + }, }, - }, - current: id, - } - }) - return redirect("/auth") + current: id, + } + }) + return redirect("/auth") + } catch (e: any) { + return new Response( + JSON.stringify({ + error: e.message, + cause: Object.fromEntries(url.searchParams.entries()), + }), + { status: 500 }, + ) + } } diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 742e0d567ce..082564b21ce 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -123,7 +123,11 @@ export default { }, }).then((x) => x.json())) as any subject = user.id.toString() - email = emails.find((x: any) => x.primary && x.verified)?.email + + const primaryEmail = emails.find((x: any) => x.primary) + if (!primaryEmail) throw new Error("No primary email found for GitHub user") + if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified") + email = primaryEmail.email } else if (response.provider === "google") { if (!response.id.email_verified) throw new Error("Google email not verified") subject = response.id.sub as string From ed06de5e307c386f6aaf6294ea614deb64ddad30 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 26 Dec 2025 19:31:42 -0500 Subject: [PATCH 033/101] core: add configurable compaction settings to allow users to disable auto-compaction and pruning via config instead of flags --- packages/opencode/src/config/config.ts | 14 ++++++++++++++ packages/opencode/src/session/compaction.ts | 10 ++++++---- packages/opencode/src/session/prompt.ts | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3350cf8f6a6..c94a34be0e6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -141,6 +141,14 @@ export namespace Config { if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) + // Apply flag overrides for compaction settings + if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { + result.compaction = { ...result.compaction, auto: false } + } + if (Flag.OPENCODE_DISABLE_PRUNE) { + result.compaction = { ...result.compaction, prune: false } + } + return { config: result, directories, @@ -791,6 +799,12 @@ export namespace Config { url: z.string().optional().describe("Enterprise URL"), }) .optional(), + compaction: z + .object({ + auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), + prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + }) + .optional(), experimental: z .object({ hook: z diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 339ba2f42a1..b3d17794b95 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -7,13 +7,13 @@ import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" import z from "zod" import { SessionPrompt } from "./prompt" -import { Flag } from "../flag/flag" import { Token } from "../util/token" import { Log } from "../util/log" import { SessionProcessor } from "./processor" import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" +import { Config } from "@/config/config" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -27,8 +27,9 @@ export namespace SessionCompaction { ), } - export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { - if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false + export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + const config = await Config.get() + if ((config.compaction?.auto ?? true) === false) return false const context = input.model.limit.context if (context === 0) return false const count = input.tokens.input + input.tokens.cache.read + input.tokens.output @@ -46,7 +47,8 @@ export namespace SessionCompaction { // calls. then erases output of previous tool calls. idea is to throw away old // tool calls that are no longer relevant. export async function prune(input: { sessionID: string }) { - if (Flag.OPENCODE_DISABLE_PRUNE) return + const config = await Config.get() + if ((config.compaction?.prune ?? true) === false) return log.info("pruning") const msgs = await Session.messages({ sessionID: input.sessionID }) let total = 0 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fabe3fa5128..19dc90b3bcb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -459,7 +459,7 @@ export namespace SessionPrompt { if ( lastFinished && lastFinished.summary !== true && - SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }) + (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) ) { await SessionCompaction.create({ sessionID, From f8fb08b3b42007d6b1ab995e6c55d1996050de69 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Dec 2025 00:32:34 +0000 Subject: [PATCH 034/101] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 10 ++++++++++ packages/sdk/openapi.json | 13 +++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6b5cfc818d0..90b2154e18a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1586,6 +1586,16 @@ export type Config = { */ url?: string } + compaction?: { + /** + * Enable automatic compaction when context is full (default: true) + */ + auto?: boolean + /** + * Enable pruning of old tool outputs (default: true) + */ + prune?: boolean + } experimental?: { hook?: { file_edited?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2a2984854f8..c3658a90c50 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8566,6 +8566,19 @@ } } }, + "compaction": { + "type": "object", + "properties": { + "auto": { + "description": "Enable automatic compaction when context is full (default: true)", + "type": "boolean" + }, + "prune": { + "description": "Enable pruning of old tool outputs (default: true)", + "type": "boolean" + } + } + }, "experimental": { "type": "object", "properties": { From 2cdc88d295f3cdac432ea81657d2f5f9da2bde45 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 26 Dec 2025 19:44:31 -0500 Subject: [PATCH 035/101] core: add compaction config tests to verify auto and prune settings work correctly --- packages/opencode/test/config/config.test.ts | 94 ++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 6f43cab6174..8871fd50bab 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -533,3 +533,97 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) }) + +test("compaction config defaults to true when not specified", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + // When not specified, compaction should be undefined (defaults handled in usage) + expect(config.compaction).toBeUndefined() + }, + }) +}) + +test("compaction config can disable auto compaction", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + compaction: { + auto: false, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.compaction?.auto).toBe(false) + expect(config.compaction?.prune).toBeUndefined() + }, + }) +}) + +test("compaction config can disable prune", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + compaction: { + prune: false, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.compaction?.prune).toBe(false) + expect(config.compaction?.auto).toBeUndefined() + }, + }) +}) + +test("compaction config can disable both auto and prune", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + compaction: { + auto: false, + prune: false, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.compaction?.auto).toBe(false) + expect(config.compaction?.prune).toBe(false) + }, + }) +}) From 2b054bec9582b6a6ba421d5ea40576878f8e59e8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 26 Dec 2025 19:48:56 -0500 Subject: [PATCH 036/101] core: fix compaction config checks to properly respect user settings --- packages/opencode/src/session/compaction.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index b3d17794b95..42bab2eb975 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -29,7 +29,7 @@ export namespace SessionCompaction { export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { const config = await Config.get() - if ((config.compaction?.auto ?? true) === false) return false + if (config.compaction?.auto === false) return false const context = input.model.limit.context if (context === 0) return false const count = input.tokens.input + input.tokens.cache.read + input.tokens.output @@ -48,7 +48,7 @@ export namespace SessionCompaction { // tool calls that are no longer relevant. export async function prune(input: { sessionID: string }) { const config = await Config.get() - if ((config.compaction?.prune ?? true) === false) return + if (config.compaction?.prune === false) return log.info("pruning") const msgs = await Session.messages({ sessionID: input.sessionID }) let total = 0 From 4385fa4dd79955cdb1d7086365ee1a238ebf9748 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 26 Dec 2025 20:47:13 -0600 Subject: [PATCH 037/101] fix(desktop): prompt input fixes, directory and branch in status bar --- packages/app/src/components/prompt-input.tsx | 161 +++++++++++++++++-- packages/app/src/components/status-bar.tsx | 26 ++- packages/app/src/context/global-sync.tsx | 8 + 3 files changed, 176 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 03fa02fe35d..2407fe97a9c 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -82,6 +82,37 @@ export const PromptInput: Component = (props) => { const command = useCommand() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement + let scrollRef!: HTMLDivElement + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const rect = range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - padding) { + container.scrollTop = bottom - container.clientHeight + padding + } + } + + const queueScroll = () => { + requestAnimationFrame(scrollCursorIntoView) + } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -153,6 +184,7 @@ export const PromptInput: Component = (props) => { editorRef.focus() setCursorPosition(editorRef, length) setStore("applyingHistory", false) + queueScroll() }) } @@ -357,9 +389,23 @@ export const PromptInput: Component = (props) => { (currentParts) => { const domParts = parseFromDOM() const normalized = Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) return true + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" + if (!prevIsBr && !nextIsBr) return false + if (nextIsBr && !prevIsBr && prev) return false + return true + } if (node.nodeType !== Node.ELEMENT_NODE) return false - return (node as HTMLElement).dataset.type === "file" + const el = node as HTMLElement + if (el.dataset.type === "file") return true + return el.tagName === "BR" }) if (normalized && isPromptEqual(currentParts, domParts)) return @@ -372,7 +418,7 @@ export const PromptInput: Component = (props) => { editorRef.innerHTML = "" currentParts.forEach((part) => { if (part.type === "text") { - editorRef.appendChild(document.createTextNode(part.content)) + editorRef.appendChild(createTextFragment(part.content)) } else if (part.type === "file") { const pill = document.createElement("span") pill.textContent = part.content @@ -398,7 +444,7 @@ export const PromptInput: Component = (props) => { let buffer = "" const flushText = () => { - const content = buffer.replace(/\r\n?/g, "\n") + const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "") buffer = "" if (!content) return parts.push({ type: "text", content, start: position, end: position + content.length }) @@ -472,6 +518,7 @@ export const PromptInput: Component = (props) => { if (prompt.dirty()) { prompt.set(DEFAULT_PROMPT, 0) } + queueScroll() return } @@ -500,6 +547,7 @@ export const PromptInput: Component = (props) => { } prompt.set(rawParts, cursorPosition) + queueScroll() } const addPart = (part: ContentPart) => { @@ -529,9 +577,10 @@ export const PromptInput: Component = (props) => { const nodes = Array.from(editorRef.childNodes) for (const node of nodes) { - const length = node.textContent?.length ?? 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { if (edge === "start") range.setStart(node, remaining) @@ -539,7 +588,7 @@ export const PromptInput: Component = (props) => { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && remaining <= length) { if (edge === "start" && remaining === 0) range.setStartBefore(node) if (edge === "start" && remaining > 0) range.setStartAfter(node) if (edge === "end" && remaining === 0) range.setEndBefore(node) @@ -565,11 +614,25 @@ export const PromptInput: Component = (props) => { selection.removeAllRanges() selection.addRange(range) } else if (part.type === "text") { - const textNode = document.createTextNode(part.content) const range = selection.getRangeAt(0) + const fragment = createTextFragment(part.content) + const last = fragment.lastChild range.deleteContents() - range.insertNode(textNode) - range.setStartAfter(textNode) + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(last) + } + } range.collapse(true) selection.removeAllRanges() selection.addRange(range) @@ -646,6 +709,24 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + if (event.key === "!" && store.mode === "normal") { const cursorPosition = getCursorPosition(editorRef) if (cursorPosition === 0) { @@ -686,7 +767,10 @@ export const PromptInput: Component = (props) => { const cursorPosition = getCursorPosition(editorRef) const textLength = promptLength(prompt.current()) - const textContent = editorRef.textContent ?? "" + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") const isEmpty = textContent.trim() === "" || textLength <= 1 const hasNewlines = textContent.includes("\n") const inHistory = store.historyIndex >= 0 @@ -978,7 +1062,7 @@ export const PromptInput: Component = (props) => { -
+
(scrollRef = el)}>
{ @@ -1119,23 +1203,56 @@ export const PromptInput: Component = (props) => { ) } +function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + const segments = content.split("\n") + segments.forEach((segment, index) => { + if (segment) { + fragment.appendChild(document.createTextNode(segment)) + } else if (segments.length > 1) { + fragment.appendChild(document.createTextNode("\u200B")) + } + if (index < segments.length - 1) { + fragment.appendChild(document.createElement("br")) + } + }) + return fragment +} + +function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let length = 0 + for (const child of Array.from(node.childNodes)) { + length += getTextLength(child) + } + return length +} + function getCursorPosition(parent: HTMLElement): number { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) return 0 const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 const preCaretRange = range.cloneRange() preCaretRange.selectNodeContents(parent) preCaretRange.setEnd(range.startContainer, range.startOffset) - return preCaretRange.toString().length + return getTextLength(preCaretRange.cloneContents()) } function setCursorPosition(parent: HTMLElement, position: number) { let remaining = position let node = parent.firstChild while (node) { - const length = node.textContent ? node.textContent.length : 0 + const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { const range = document.createRange() @@ -1147,10 +1264,24 @@ function setCursorPosition(parent: HTMLElement, position: number) { return } - if (isFile && remaining <= length) { + if ((isFile || isBreak) && remaining <= length) { const range = document.createRange() const selection = window.getSelection() - range.setStartAfter(node) + if (remaining === 0) { + range.setStartBefore(node) + } + if (remaining > 0 && isFile) { + range.setStartAfter(node) + } + if (remaining > 0 && isBreak) { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) { + range.setStart(next, 0) + } + if (!next || next.nodeType !== Node.TEXT_NODE) { + range.setStartAfter(node) + } + } range.collapse(true) selection?.removeAllRanges() selection?.addRange(range) diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx index e0e25c60b8b..d8a88503f20 100644 --- a/packages/app/src/components/status-bar.tsx +++ b/packages/app/src/components/status-bar.tsx @@ -1,13 +1,31 @@ -import { Show, type ParentProps } from "solid-js" +import { createMemo, Show, type ParentProps } from "solid-js" import { usePlatform } from "@/context/platform" +import { useSync } from "@/context/sync" +import { useGlobalSync } from "@/context/global-sync" export function StatusBar(props: ParentProps) { const platform = usePlatform() + const sync = useSync() + const globalSync = useGlobalSync() + + const directoryDisplay = createMemo(() => { + const directory = sync.data.path.directory || "" + const home = globalSync.data.path.home || "" + const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory + const branch = sync.data.vcs?.branch + return branch ? `${short}:${branch}` : short + }) + return (
- - v{platform.version} - +
+ + v{platform.version} + + + {directoryDisplay()} + +
{props.children}
) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 7a9dc8dc425..c51901eb254 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -14,6 +14,7 @@ import { type Command, type McpStatus, type LspStatus, + type VcsInfo, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -47,6 +48,7 @@ type State = { [name: string]: McpStatus } lsp: LspStatus[] + vcs: VcsInfo | undefined limit: number message: { [sessionID: string]: Message[] @@ -93,6 +95,7 @@ function createGlobalSync() { todo: {}, mcp: {}, lsp: [], + vcs: undefined, limit: 5, message: {}, part: {}, @@ -159,6 +162,7 @@ function createGlobalSync() { config: () => sdk.config.get().then((x) => setStore("config", x.data!)), mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), + vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) @@ -305,6 +309,10 @@ function createGlobalSync() { } break } + case "vcs.branch.updated": { + setStore("vcs", { branch: event.properties.branch }) + break + } } }) From 1bcc72c477556088a0a717413dba7acaf58fe204 Mon Sep 17 00:00:00 2001 From: ja <51257127+anntnzrb@users.noreply.github.com> Date: Fri, 26 Dec 2025 23:12:35 -0500 Subject: [PATCH 038/101] feat: add ability to disable spinner animation (#6084) --- .../cli/cmd/tui/component/dialog-session-list.tsx | 10 ++++++++-- .../src/cli/cmd/tui/component/prompt/index.tsx | 9 +++++++-- .../src/cli/cmd/tui/routes/session/index.tsx | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 1217bb54ae0..cb7b5d282ee 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createEffect, createMemo, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" import { Locale } from "@/util/locale" import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" +import { useKV } from "../context/kv" import "opentui-spinner/solid" export function DialogSessionList() { @@ -16,6 +17,7 @@ export function DialogSessionList() { const { theme } = useTheme() const route = useRoute() const sdk = useSDK() + const kv = useKV() const [toDelete, setToDelete] = createSignal() @@ -45,7 +47,11 @@ export function DialogSessionList() { value: x.id, category, footer: Locale.time(x.time.updated), - gutter: isWorking ? : undefined, + gutter: isWorking ? ( + [⋯]}> + + + ) : undefined, } }) .slice(0, 150) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 9494b81cb10..f819746d53c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -29,6 +29,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" +import { useKV } from "../../context/kv" export type PromptProps = { sessionID?: string @@ -124,6 +125,7 @@ export function Prompt(props: PromptProps) { const command = useCommandDialog() const renderer = useRenderer() const { theme, syntax } = useTheme() + const kv = useKV() function promptModelWarning() { toast.show({ @@ -996,8 +998,11 @@ export function Prompt(props: PromptProps) { justifyContent={status().type === "retry" ? "space-between" : "flex-start"} > - {/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} - + + [⋯]}> + + + {(() => { const retry = createMemo(() => { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 818b96da43b..177c43a463a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -128,6 +128,7 @@ export function Session() { const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") + const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true)) const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -584,6 +585,19 @@ export function Session() { dialog.clear() }, }, + { + title: animationsEnabled() ? "Disable animations" : "Enable animations", + value: "session.toggle.animations", + category: "Session", + onSelect: (dialog) => { + setAnimationsEnabled((prev) => { + const next = !prev + kv.set("animations_enabled", next) + return next + }) + dialog.clear() + }, + }, { title: "Page up", value: "session.page.up", From bfb9787361af01cf2eb8c6dbb2dd7dd08e91496f Mon Sep 17 00:00:00 2001 From: Christopher Ochsenreither Date: Fri, 26 Dec 2025 21:57:59 -0700 Subject: [PATCH 039/101] fix: compact command after revert now properly cleans up revert state (#6235) --- packages/opencode/src/server/server.ts | 2 + .../test/session/revert-compact.test.ts | 285 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 packages/opencode/test/session/revert-compact.test.ts diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 65393e12897..b15fb6196ca 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1084,6 +1084,8 @@ export namespace Server { async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") + const session = await Session.get(sessionID) + await SessionRevert.cleanup(session) const msgs = await Session.messages({ sessionID }) let currentAgent = await Agent.defaultAgent() for (let i = msgs.length - 1; i >= 0; i--) { diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts new file mode 100644 index 00000000000..de2b14573f4 --- /dev/null +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import path from "path" +import { Session } from "../../src/session" +import { SessionRevert } from "../../src/session/revert" +import { SessionCompaction } from "../../src/session/compaction" +import { MessageV2 } from "../../src/session/message-v2" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Identifier } from "../../src/id/id" +import { tmpdir } from "../fixture/fixture" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("revert + compact workflow", () => { + test("should properly handle compact command after revert", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Create a session + const session = await Session.create({}) + const sessionID = session.id + + // Create a user message + const userMsg1 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + // Add a text part to the user message + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg1.id, + sessionID, + type: "text", + text: "Hello, please help me", + }) + + // Create an assistant response message + const assistantMsg1: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg1.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg1) + + // Add a text part to the assistant message + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg1.id, + sessionID, + type: "text", + text: "Sure, I'll help you!", + }) + + // Create another user message + const userMsg2 = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg2.id, + sessionID, + type: "text", + text: "What's the capital of France?", + }) + + // Create another assistant response + const assistantMsg2: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg2.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg2) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg2.id, + sessionID, + type: "text", + text: "The capital of France is Paris.", + }) + + // Verify messages before revert + let messages = await Session.messages({ sessionID }) + expect(messages.length).toBe(4) // 2 user + 2 assistant messages + const messageIds = messages.map((m) => m.info.id) + expect(messageIds).toContain(userMsg1.id) + expect(messageIds).toContain(userMsg2.id) + expect(messageIds).toContain(assistantMsg1.id) + expect(messageIds).toContain(assistantMsg2.id) + + // Revert the last user message (userMsg2) + await SessionRevert.revert({ + sessionID, + messageID: userMsg2.id, + }) + + // Check that revert state is set + let sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeDefined() + const revertMessageID = sessionInfo.revert?.messageID + expect(revertMessageID).toBeDefined() + + // Messages should still be in the list (not removed yet, just marked for revert) + messages = await Session.messages({ sessionID }) + expect(messages.length).toBe(4) + + // Now clean up the revert state (this is what the compact endpoint should do) + await SessionRevert.cleanup(sessionInfo) + + // After cleanup, the reverted messages (those after the revert point) should be removed + messages = await Session.messages({ sessionID }) + const remainingIds = messages.map((m) => m.info.id) + // The revert point is somewhere in the message chain, so we should have fewer messages + expect(messages.length).toBeLessThan(4) + // userMsg2 and assistantMsg2 should be removed (they come after the revert point) + expect(remainingIds).not.toContain(userMsg2.id) + expect(remainingIds).not.toContain(assistantMsg2.id) + + // Revert state should be cleared + sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeUndefined() + + // Clean up + await Session.remove(sessionID) + }, + }) + }) + + test("should properly clean up revert state before creating compaction message", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Create a session + const session = await Session.create({}) + const sessionID = session.id + + // Create initial messages + const userMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID, + agent: "default", + model: { + providerID: "openai", + modelID: "gpt-4", + }, + time: { + created: Date.now(), + }, + }) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID, + type: "text", + text: "Hello", + }) + + const assistantMsg: MessageV2.Assistant = { + id: Identifier.ascending("message"), + role: "assistant", + sessionID, + mode: "default", + agent: "default", + path: { + cwd: tmp.path, + root: tmp.path, + }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: "gpt-4", + providerID: "openai", + parentID: userMsg.id, + time: { + created: Date.now(), + }, + finish: "end_turn", + } + await Session.updateMessage(assistantMsg) + + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: assistantMsg.id, + sessionID, + type: "text", + text: "Hi there!", + }) + + // Revert the user message + await SessionRevert.revert({ + sessionID, + messageID: userMsg.id, + }) + + // Check that revert state is set + let sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeDefined() + + // Simulate what the compact endpoint does: cleanup revert before creating compaction + await SessionRevert.cleanup(sessionInfo) + + // Verify revert state is cleared + sessionInfo = await Session.get(sessionID) + expect(sessionInfo.revert).toBeUndefined() + + // Verify messages are properly cleaned up + const messages = await Session.messages({ sessionID }) + expect(messages.length).toBe(0) // All messages should be reverted + + // Clean up + await Session.remove(sessionID) + }, + }) + }) +}) From 3c02d5d338698d382b02fbbf7eaa07a9b0f1ebc6 Mon Sep 17 00:00:00 2001 From: rari404 <138394996+edlsh@users.noreply.github.com> Date: Sat, 27 Dec 2025 00:20:07 -0500 Subject: [PATCH 040/101] feat: add path traversal protection to File.read and File.list (#5985) --- packages/opencode/src/file/index.ts | 15 +++ .../opencode/test/file/path-traversal.test.ts | 115 ++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 packages/opencode/test/file/path-traversal.test.ts diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 148ab45cb07..9462ec57369 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -7,6 +7,7 @@ import path from "path" import fs from "fs" import ignore from "ignore" import { Log } from "../util/log" +import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" import { Ripgrep } from "./ripgrep" import fuzzysort from "fuzzysort" @@ -235,6 +236,13 @@ export namespace File { using _ = log.time("read", { file }) const project = Instance.project const full = path.join(Instance.directory, file) + + // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. + // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + if (!Filesystem.contains(Instance.directory, full)) { + throw new Error(`Access denied: path escapes project directory`) + } + const bunFile = Bun.file(full) if (!(await bunFile.exists())) { @@ -288,6 +296,13 @@ export namespace File { ignored = ig.ignores.bind(ig) } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory + + // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. + // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. + if (!Filesystem.contains(Instance.directory, resolved)) { + throw new Error(`Access denied: path escapes project directory`) + } + const nodes: Node[] = [] for (const entry of await fs.promises .readdir(resolved, { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts new file mode 100644 index 00000000000..c20c76a2e7f --- /dev/null +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -0,0 +1,115 @@ +import { test, expect, describe } from "bun:test" +import path from "path" +import { Filesystem } from "../../src/util/filesystem" +import { File } from "../../src/file" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("Filesystem.contains", () => { + test("allows paths within project", () => { + expect(Filesystem.contains("/project", "/project/src")).toBe(true) + expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true) + expect(Filesystem.contains("/project", "/project")).toBe(true) + }) + + test("blocks ../ traversal", () => { + expect(Filesystem.contains("/project", "/project/../etc")).toBe(false) + expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false) + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + }) + + test("blocks absolute paths outside project", () => { + expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("/project", "/tmp/file")).toBe(false) + expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false) + }) + + test("handles prefix collision edge cases", () => { + expect(Filesystem.contains("/project", "/project-other/file")).toBe(false) + expect(Filesystem.contains("/project", "/projectfile")).toBe(false) + }) +}) + +/* + * Integration tests for File.read() and File.list() path traversal protection. + * + * These tests verify the HTTP API code path is protected. The HTTP endpoints + * in server.ts (GET /file/content, GET /file) call File.read()/File.list() + * directly - they do NOT go through ReadTool or the agent permission layer. + * + * This is a SEPARATE code path from ReadTool, which has its own checks. + */ +describe("File.read path traversal protection", () => { + test("rejects ../ traversal attempting to read /etc/passwd", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "allowed.txt"), "allowed content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("rejects deeply nested traversal", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( + "Access denied: path escapes project directory", + ) + }, + }) + }) + + test("allows valid paths within project", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "valid.txt"), "valid content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("valid.txt") + expect(result.content).toBe("valid content") + }, + }) + }) +}) + +describe("File.list path traversal protection", () => { + test("rejects ../ traversal attempting to list /etc", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") + }, + }) + }) + + test("allows valid subdirectory listing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "subdir", "file.txt"), "content") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.list("subdir") + expect(Array.isArray(result)).toBe(true) + }, + }) + }) +}) From e6b9988fa4913d25fe2f6bf5b0f3b7218f8c8e7e Mon Sep 17 00:00:00 2001 From: Didier Durand <2927957+didier-durand@users.noreply.github.com> Date: Sat, 27 Dec 2025 07:46:06 +0100 Subject: [PATCH 041/101] doc: fix typos in various files (#6238) --- .github/workflows/review.yml | 2 +- packages/opencode/src/agent/prompt/title.txt | 2 +- packages/opencode/src/session/prompt/copilot-gpt-5.txt | 2 +- packages/opencode/src/session/prompt/qwen.txt | 2 +- packages/opencode/src/tool/bash.txt | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index c0e3a5deb15..44bfeb33661 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -64,7 +64,7 @@ jobs: Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage. - When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) + When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block. If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors. diff --git a/packages/opencode/src/agent/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt index f67aaa95bac..7e927b797ce 100644 --- a/packages/opencode/src/agent/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -22,7 +22,7 @@ Your output must be: - The title should NEVER include "summarizing" or "generating" when generating a title - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): +- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"): → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) diff --git a/packages/opencode/src/session/prompt/copilot-gpt-5.txt b/packages/opencode/src/session/prompt/copilot-gpt-5.txt index 81594301944..f8e3e6b8c98 100644 --- a/packages/opencode/src/session/prompt/copilot-gpt-5.txt +++ b/packages/opencode/src/session/prompt/copilot-gpt-5.txt @@ -129,7 +129,7 @@ Tools can be disabled by the user. You may see tools used previously in the conv Use proper Markdown formatting in your answers. When referring to a filename or symbol in the user's workspace, wrap it in backticks. When sharing setup or run steps for the user to execute, render commands in fenced code blocks with an appropriate language tag (`bash`, `sh`, `powershell`, `python`, etc.). Keep one command per line; avoid prose-only representations of commands. -Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multi-step tasks, maintain a lightweight checklist implicitly and weave progress into your narration. +Keep responses conversational and fun—use a brief, friendly preamble that acknowledges the goal and states what you're about to do next. Avoid literal scaffold labels like "Plan:", "Task receipt:", or "Actions:"; instead, use short paragraphs and, when helpful, concise bullet lists. Do not start with filler acknowledgements (e.g., "Sounds good", "Great", "Okay, I will…"). For multistep tasks, maintain a lightweight checklist implicitly and weave progress into your narration. For section headers in your response, use level-2 Markdown headings (`##`) for top-level sections and level-3 (`###`) for subsections. Choose titles dynamically to match the task and content. Do not hard-code fixed section names; create only the sections that make sense and only when they have non-empty content. Keep headings short and descriptive (e.g., "actions taken", "files changed", "how to run", "performance", "notes"), and order them naturally (actions > artifacts > how to run > performance > notes) when applicable. You may add a tasteful emoji to a heading when it improves scannability; keep it minimal and professional. Headings must start at the beginning of the line with `## ` or `### `, have a blank line before and after, and must not be inside lists, block quotes, or code fences. When listing files created/edited, include a one-line purpose for each file when helpful. In performance sections, base any metrics on actual runs from this session; note the hardware/OS context and mark estimates clearly—never fabricate numbers. In "Try it" sections, keep commands copyable; comments starting with `#` are okay, but put each command on its own line. If platform-specific acceleration applies, include an optional speed-up fenced block with commands. Close with a concise completion summary describing what changed and how it was verified (build/tests/linters), plus any follow-ups. diff --git a/packages/opencode/src/session/prompt/qwen.txt b/packages/opencode/src/session/prompt/qwen.txt index a34fdb01a05..d88d9d063ba 100644 --- a/packages/opencode/src/session/prompt/qwen.txt +++ b/packages/opencode/src/session/prompt/qwen.txt @@ -84,7 +84,7 @@ The user will primarily request you perform software engineering tasks. This inc - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. - Implement the solution using all tools available to you - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 18ee14012b2..c31263c04eb 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -22,7 +22,7 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). + - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds 30000 characters, output will be truncated before being returned to you. - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. @@ -70,7 +70,7 @@ Git Safety Protocol: - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. 2. Analyze all staged changes (both previously staged and newly added) and draft a commit message: - Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.). - - Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files + - Do not commit files that likely contain secrets (.env, credentials.json, etc.). Warn the user if they specifically request to commit those files - Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what" - Ensure it accurately reflects the changes and their purpose 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands: From 4667d57e3c351a168789edfea0bbf440220adfc5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 27 Dec 2025 00:50:59 -0600 Subject: [PATCH 042/101] ci: stale issues --- .github/workflows/stale-issues.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/stale-issues.yml diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml new file mode 100644 index 00000000000..d41e8e60c50 --- /dev/null +++ b/.github/workflows/stale-issues.yml @@ -0,0 +1,29 @@ +name: "Auto-close stale issues" + +on: + schedule: + - cron: "30 1 * * *" # Daily at 1:30 AM + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/stale@v10 + with: + days-before-stale: 90 + days-before-close: 7 + stale-issue-label: "stale" + close-issue-message: | + [automated] Closing due to 90+ days of inactivity. + + Feel free to reopen if you still need this! + stale-issue-message: | + [automated] This issue has had no activity for 90 days. + + It will be closed in 7 days if there's no new activity. + remove-stale-when-updated: true + exempt-issue-labels: "pinned,security,feature-request,on-hold" + start-date: "2025-12-27" From 685f3ea324cf5d0401f7b0895a78560149bf8a4b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Dec 2025 12:04:27 +0000 Subject: [PATCH 043/101] ignore: update download stats 2025-12-27 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 77a9d296cfc..41c93525478 100644 --- a/STATS.md +++ b/STATS.md @@ -182,3 +182,4 @@ | 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | | 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | | 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) | +| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) | From c523ca412747d66e0236865a4fa2481f7d50f64e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 27 Dec 2025 05:19:03 -0600 Subject: [PATCH 044/101] wip(desktop): handle more errors --- packages/app/src/components/header.tsx | 4 ++ packages/app/src/components/prompt-input.tsx | 60 ++++++++++++-------- packages/app/src/components/terminal.tsx | 32 ++++++----- packages/app/src/context/terminal.tsx | 60 +++++++++++++------- packages/desktop/vite.config.ts | 3 + 5 files changed, 100 insertions(+), 59 deletions(-) diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx index 3eae0e05d41..74c49f07ac6 100644 --- a/packages/app/src/components/header.tsx +++ b/packages/app/src/components/header.tsx @@ -188,6 +188,10 @@ export function Header(props: { shareURL = await globalSDK.client.session .share({ sessionID: session.id, directory: currentDirectory() }) .then((r) => r.data?.share?.url) + .catch((e) => { + console.error("Failed to share session", e) + return undefined + }) } return shareURL }, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2407fe97a9c..f1bb9132a59 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -643,9 +643,11 @@ export const PromptInput: Component = (props) => { } const abort = () => - sdk.client.session.abort({ - sessionID: params.id!, - }) + sdk.client.session + .abort({ + sessionID: params.id!, + }) + .catch(() => {}) const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt @@ -883,12 +885,16 @@ export const PromptInput: Component = (props) => { const agent = local.agent.current()!.name if (isShellMode) { - sdk.client.session.shell({ - sessionID: existing.id, - agent, - model, - command: text, - }) + sdk.client.session + .shell({ + sessionID: existing.id, + agent, + model, + command: text, + }) + .catch((e) => { + console.error("Failed to send shell command", e) + }) return } @@ -897,13 +903,17 @@ export const PromptInput: Component = (props) => { const commandName = cmdName.slice(1) const customCommand = sync.data.command.find((c) => c.name === commandName) if (customCommand) { - sdk.client.session.command({ - sessionID: existing.id, - command: commandName, - arguments: args.join(" "), - agent, - model: `${model.providerID}/${model.modelID}`, - }) + sdk.client.session + .command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent, + model: `${model.providerID}/${model.modelID}`, + }) + .catch((e) => { + console.error("Failed to send command", e) + }) return } } @@ -929,13 +939,17 @@ export const PromptInput: Component = (props) => { model, }) - sdk.client.session.prompt({ - sessionID: existing.id, - agent, - model, - messageID, - parts: requestParts, - }) + sdk.client.session + .prompt({ + sessionID: existing.id, + agent, + model, + messageID, + parts: requestParts, + }) + .catch((e) => { + console.error("Failed to send prompt", e) + }) } return ( diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c05ddfbf635..abf03fc0640 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -82,13 +82,15 @@ export const Terminal = (props: TerminalProps) => { window.addEventListener("resize", handleResize) term.onResize(async (size) => { if (ws && ws.readyState === WebSocket.OPEN) { - await sdk.client.pty.update({ - ptyID: local.pty.id, - size: { - cols: size.cols, - rows: size.rows, - }, - }) + await sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { + cols: size.cols, + rows: size.rows, + }, + }) + .catch(() => {}) } }) term.onData((data) => { @@ -106,13 +108,15 @@ export const Terminal = (props: TerminalProps) => { // }) ws.addEventListener("open", () => { console.log("WebSocket connected") - sdk.client.pty.update({ - ptyID: local.pty.id, - size: { - cols: term.cols, - rows: term.rows, - }, - }) + sdk.client.pty + .update({ + ptyID: local.pty.id, + size: { + cols: term.cols, + rows: term.rows, + }, + }) + .catch(() => {}) }) ws.addEventListener("message", (event) => { term.write(event.data) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 6f7c11dea8c..e9a07077cef 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), new() { - sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - }, - ]) - setStore("active", id) - }) + sdk.client.pty + .create({ title: `Terminal ${store.all.length + 1}` }) + .then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + .catch((e) => { + console.error("Failed to create terminal", e) + }) }, update(pty: Partial & { id: string }) { setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty.update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) + sdk.client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((e) => { + console.error("Failed to update terminal", e) + }) }, async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const clone = await sdk.client.pty.create({ - title: pty.title, - }) - if (!clone.data) return + const clone = await sdk.client.pty + .create({ + title: pty.title, + }) + .catch((e) => { + console.error("Failed to clone terminal", e) + return undefined + }) + if (!clone?.data) return setStore("all", index, { ...pty, ...clone.data, @@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont setStore("active", previous?.id) } }) - await sdk.client.pty.remove({ ptyID: id }) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { + console.error("Failed to close terminal", e) + }) }, move(id: string, to: number) { const index = store.all.findIndex((f) => f.id === id) diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 123a2028c91..6d4f62dc2cb 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ // // 1. prevent Vite from obscuring rust errors clearScreen: false, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, From 21eba5f987482b4e2e75ab1c564815bd7b0613f4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 27 Dec 2025 05:16:39 -0600 Subject: [PATCH 045/101] feat(desktop): permissions --- packages/app/src/context/global-sync.tsx | 61 ++++++ packages/app/src/context/local.tsx | 25 ++- packages/app/src/pages/directory-layout.tsx | 11 +- packages/app/src/pages/layout.tsx | 50 ++++- packages/opencode/src/permission/index.ts | 11 + packages/opencode/src/server/server.ts | 22 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 18 ++ packages/sdk/openapi.json | 37 ++++ packages/ui/src/components/basic-tool.tsx | 11 +- packages/ui/src/components/message-part.css | 95 ++++++++ packages/ui/src/components/message-part.tsx | 226 ++++++++++++++++---- packages/ui/src/components/session-turn.css | 8 + packages/ui/src/components/session-turn.tsx | 23 ++ packages/ui/src/components/toast.tsx | 10 +- packages/ui/src/context/data.tsx | 14 +- packages/ui/src/context/dialog.tsx | 4 - 17 files changed, 586 insertions(+), 60 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index c51901eb254..50c8a9d1c87 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -15,6 +15,7 @@ import { type McpStatus, type LspStatus, type VcsInfo, + type Permission, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -44,6 +45,9 @@ type State = { todo: { [sessionID: string]: Todo[] } + permission: { + [sessionID: string]: Permission[] + } mcp: { [name: string]: McpStatus } @@ -78,6 +82,7 @@ function createGlobalSync() { }) const children: Record>> = {} + const permissionListeners: Set<(info: { directory: string; permission: Permission }) => void> = new Set() function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -93,6 +98,7 @@ function createGlobalSync() { session_status: {}, session_diff: {}, todo: {}, + permission: {}, mcp: {}, lsp: [], vcs: undefined, @@ -163,6 +169,15 @@ function createGlobalSync() { mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)), + permission: () => + sdk.permission.list().then((x) => { + const grouped: Record = {} + for (const perm of x.data ?? []) { + grouped[perm.sessionID] = grouped[perm.sessionID] ?? [] + grouped[perm.sessionID]!.push(perm) + } + setStore("permission", grouped) + }), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) @@ -313,6 +328,46 @@ function createGlobalSync() { setStore("vcs", { branch: event.properties.branch }) break } + case "permission.updated": { + const permissions = store.permission[event.properties.sessionID] + const isNew = !permissions || !permissions.find((p) => p.id === event.properties.id) + if (!permissions) { + setStore("permission", event.properties.sessionID, [event.properties]) + } else { + const result = Binary.search(permissions, event.properties.id, (p) => p.id) + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + if (result.found) { + draft[result.index] = event.properties + return + } + draft.push(event.properties) + }), + ) + } + if (isNew) { + for (const listener of permissionListeners) { + listener({ directory, permission: event.properties }) + } + } + break + } + case "permission.replied": { + const permissions = store.permission[event.properties.sessionID] + if (!permissions) break + const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id) + if (!result.found) break + setStore( + "permission", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } } }) @@ -384,6 +439,12 @@ function createGlobalSync() { project: { loadSessions, }, + permission: { + onUpdated(listener: (info: { directory: string; permission: Permission }) => void) { + permissionListeners.add(listener) + return () => permissionListeners.delete(listener) + }, + }, } } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 600a0e4b160..49217b82be8 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const list = async (path: string) => { - return sdk.client.file.list({ path: path + "/" }).then((x) => { - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) - }) + return sdk.client.file + .list({ path: path + "/" }) + .then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + .catch(() => {}) } const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index c909a373d56..04f90bdcbf6 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,6 +1,6 @@ import { createMemo, Show, type ParentProps } from "solid-js" import { useParams } from "@solidjs/router" -import { SDKProvider } from "@/context/sdk" +import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" @@ -18,8 +18,15 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() + const sdk = useSDK() return ( - + { + sdk.client.permission.respond(input) + }} + > {props.children} ) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5efba6d994b..538a3b8409d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -117,6 +117,39 @@ export default function Layout(props: ParentProps) { } }) + onMount(() => { + const unsub = globalSync.permission.onUpdated(({ directory, permission }) => { + const currentDir = params.dir ? base64Decode(params.dir) : undefined + const currentSession = params.id + if (directory === currentDir && permission.sessionID === currentSession) return + const [store] = globalSync.child(directory) + const session = store.session.find((s) => s.id === permission.sessionID) + if (directory === currentDir && session?.parentID === currentSession) return + const sessionTitle = session?.title ?? "New session" + const projectName = getFilename(directory) + showToast({ + persistent: true, + icon: "checklist", + title: "Permission required", + description: `${sessionTitle} in ${projectName} needs permission`, + actions: [ + { + label: "Go to session", + onClick: () => { + navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`) + }, + dismissAfter: true, + }, + { + label: "Dismiss", + onClick: "dismiss", + }, + ], + }) + }) + onCleanup(unsub) + }) + function sortSessions(a: Session, b: Session) { const now = Date.now() const oneMinuteAgo = now - 60 * 1000 @@ -454,8 +487,20 @@ export default function Layout(props: ParentProps) { const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const notifications = createMemo(() => notification.session.unseen(props.session.id)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const hasPermissions = createMemo(() => { + const store = globalSync.child(props.project.worktree)[0] + const permissions = store.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + const childSessions = store.session.filter((s) => s.parentID === props.session.id) + for (const child of childSessions) { + const childPermissions = store.permission?.[child.id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) const isWorking = createMemo(() => { if (props.session.id === params.id) return false + if (hasPermissions()) return false const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] return status?.type === "busy" || status?.type === "retry" }) @@ -486,6 +531,9 @@ export default function Layout(props: ParentProps) { + +
+
@@ -587,7 +635,7 @@ export default function Layout(props: ParentProps) { closeProject(props.project.worktree)}> - Close Project + Close project diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index f3a8852ae4f..cbfeb6a9b9a 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -86,6 +86,17 @@ export namespace Permission { return state().pending } + export function list() { + const { pending } = state() + const result: Info[] = [] + for (const items of Object.values(pending)) { + for (const item of Object.values(items)) { + result.push(item.info) + } + } + return result.sort((a, b) => a.id.localeCompare(b.id)) + } + export async function ask(input: { type: Info["type"] title: Info["title"] diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b15fb6196ca..e25d9ded473 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1532,6 +1532,28 @@ export namespace Server { return c.json(true) }, ) + .get( + "/permission", + describeRoute({ + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + operationId: "permission.list", + responses: { + 200: { + description: "List of pending permissions", + content: { + "application/json": { + schema: resolver(Permission.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const permissions = Permission.list() + return c.json(permissions) + }, + ) .get( "/command", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 97bc92b8669..797896ace9a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -54,6 +54,7 @@ import type { PartUpdateErrors, PartUpdateResponses, PathGetResponses, + PermissionListResponses, PermissionRespondErrors, PermissionRespondResponses, ProjectCurrentResponses, @@ -1618,6 +1619,25 @@ export class Permission extends HeyApiClient { }, }) } + + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/permission", + ...options, + ...params, + }) + } } export class Command extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90b2154e18a..5c4cc69423d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3356,6 +3356,24 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type PermissionListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/permission" +} + +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + export type CommandListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c3658a90c50..3903566b91e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2879,6 +2879,43 @@ ] } }, + "/permission": { + "get": { + "operationId": "permission.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List pending permissions", + "description": "Get all pending permission requests across all sessions.", + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Permission" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, "/command": { "get": { "operationId": "command.list", diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 28320eeb3e9..67720955dcb 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, type JSX } from "solid-js" +import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Collapsible } from "./collapsible" import { Icon, IconProps } from "./icon" @@ -24,11 +24,18 @@ export interface BasicToolProps { children?: JSX.Element hideDetails?: boolean defaultOpen?: boolean + forceOpen?: boolean } export function BasicTool(props: BasicToolProps) { + const [open, setOpen] = createSignal(props.defaultOpen ?? false) + + createEffect(() => { + if (props.forceOpen) setOpen(true) + }) + return ( - +
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 6daf1a8b513..a8a9e6a31ed 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -361,3 +361,98 @@ overflow: hidden; } } + +[data-component="tool-part-wrapper"] { + width: 100%; + + &[data-permission="true"] { + position: sticky; + top: var(--sticky-header-height, 80px); + bottom: 0px; + z-index: 10; + border-radius: 6px; + border: none; + box-shadow: var(--shadow-xs-border-base); + background-color: var(--surface-raised-base); + overflow: visible; + + &::before { + content: ""; + position: absolute; + inset: -1.5px; + border-radius: 7.5px; + border: 1.5px solid transparent; + background: + linear-gradient(var(--background-base) 0 0) padding-box, + conic-gradient( + from var(--border-angle), + transparent 0deg, + transparent 270deg, + var(--border-warning-strong, var(--border-warning-selected)) 300deg, + var(--border-warning-base) 360deg + ) + border-box; + animation: chase-border 1.5s linear infinite; + pointer-events: none; + z-index: -1; + } + + & > *:first-child { + border-top-left-radius: 6px; + border-top-right-radius: 6px; + overflow: hidden; + } + + & > *:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + overflow: hidden; + } + + [data-component="collapsible"] { + border: none; + } + + [data-component="card"] { + border: none; + } + } +} + +@property --border-angle { + syntax: ""; + initial-value: 0deg; + inherits: false; +} + +@keyframes chase-border { + from { + --border-angle: 0deg; + } + to { + --border-angle: 360deg; + } +} + +[data-component="permission-prompt"] { + display: flex; + flex-direction: column; + padding: 8px 12px; + background-color: var(--surface-raised-strong); + border-radius: 0 0 6px 6px; + + [data-slot="permission-message"] { + display: none; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + } + + [data-slot="permission-actions"] { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1424041e8c0..0a1518b796e 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,4 +1,4 @@ -import { Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js" +import { Component, createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Dynamic } from "solid-js/web" import { AssistantMessage, @@ -16,6 +16,7 @@ import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" +import { Button } from "./button" import { Card } from "./card" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -188,11 +189,6 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { } } -function getToolPartInfo(part: ToolPart): ToolInfo { - const input = part.state.input || {} - return getToolInfo(part.tool, input) -} - export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -334,6 +330,7 @@ export interface ToolProps { status?: string hideDetails?: boolean defaultOpen?: boolean + forceOpen?: boolean } export type ToolComponent = Component @@ -361,11 +358,35 @@ export const ToolRegistry = { } PART_MAPPING["tool"] = function ToolPartDisplay(props) { + const data = useData() const part = props.part as ToolPart + + const permission = createMemo(() => { + const sessionID = props.message.sessionID + const permissions = data.store.permission?.[sessionID] ?? [] + return permissions.find((p) => p.callID === part.callID) + }) + + const [forceOpen, setForceOpen] = createSignal(false) + createEffect(() => { + if (permission()) setForceOpen(true) + }) + + const respond = (response: "once" | "always" | "reject") => { + const perm = permission() + if (!perm || !data.respondToPermission) return + data.respondToPermission({ + sessionID: perm.sessionID, + permissionID: perm.id, + response, + }) + } + const component = createMemo(() => { const render = ToolRegistry.render(part.tool) ?? GenericTool - const metadata = part.state.status === "pending" ? {} : (part.state.metadata ?? {}) - const input = part.state.status === "completed" ? part.state.input : {} + // @ts-expect-error + const metadata = part.state?.metadata ?? {} + const input = part.state?.input ?? {} return ( @@ -399,9 +420,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { input={input} tool={part.tool} metadata={metadata} - output={part.state.status === "completed" ? part.state.output : undefined} + // @ts-expect-error + output={part.state.output} status={part.state.status} hideDetails={props.hideDetails} + forceOpen={forceOpen()} defaultOpen={props.defaultOpen} /> @@ -409,7 +432,29 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { ) }) - return {component()} + return ( +
+ {component()} + + {(perm) => ( +
+
{perm().title}
+
+ + + +
+
+ )} +
+
+ ) } PART_MAPPING["text"] = function TextPartDisplay(props) { @@ -564,6 +609,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "task", render(props) { + const data = useData() const summary = () => (props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[] @@ -571,35 +617,141 @@ ToolRegistry.register({ working: () => true, }) + const childSessionId = () => props.metadata.sessionId as string | undefined + + const childPermission = createMemo(() => { + const sessionId = childSessionId() + if (!sessionId) return undefined + const permissions = data.store.permission?.[sessionId] ?? [] + return permissions.toSorted((a, b) => a.id.localeCompare(b.id))[0] + }) + + const childToolPart = createMemo(() => { + const perm = childPermission() + if (!perm) return undefined + const sessionId = childSessionId() + if (!sessionId) return undefined + // Find the tool part that matches the permission's callID + const messages = data.store.message[sessionId] ?? [] + for (const msg of messages) { + const parts = data.store.part[msg.id] ?? [] + for (const part of parts) { + if (part.type === "tool" && (part as ToolPart).callID === perm.callID) { + return { part: part as ToolPart, message: msg } + } + } + } + return undefined + }) + + const respond = (response: "once" | "always" | "reject") => { + const perm = childPermission() + if (!perm || !data.respondToPermission) return + data.respondToPermission({ + sessionID: perm.sessionID, + permissionID: perm.id, + response, + }) + } + + const renderChildToolPart = () => { + const toolData = childToolPart() + if (!toolData) return null + const { part } = toolData + const render = ToolRegistry.render(part.tool) ?? GenericTool + // @ts-expect-error + const metadata = part.state?.metadata ?? {} + const input = part.state?.input ?? {} + return ( + + ) + } + return ( - -
-
- - {(item) => { - const info = getToolInfo(item.tool) - return ( -
- - {info.title} - - {item.state.title} - +
+ + + {(perm) => ( + <> + + } + > + {renderChildToolPart()} + +
+
{perm().title}
+
+ + +
- ) +
+ + )} +
+ + -
-
- + > +
+
+ + {(item) => { + const info = getToolInfo(item.tool) + return ( +
+ + {info.title} + + {item.state.title} + +
+ ) + }} +
+
+
+ + + +
) }, }) @@ -618,7 +770,7 @@ ToolRegistry.register({ >
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 63c77e5ac78..1748feab963 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -357,4 +357,12 @@ margin-top: 0; } } + + [data-slot="session-turn-permission-parts"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a0368b0d492..ce4845a71c0 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -151,6 +151,22 @@ export function SessionTurn( return false }) + const permissionParts = createMemo(() => { + const result: { part: ToolPart; message: AssistantMessage }[] = [] + const permissions = data.store.permission?.[props.sessionID] ?? [] + if (!permissions.length) return result + + for (const m of assistantMessages()) { + const msgParts = data.store.part[m.id] ?? [] + for (const p of msgParts) { + if (p?.type === "tool" && permissions.some((perm) => perm.callID === (p as ToolPart).callID)) { + result.push({ part: p as ToolPart, message: m }) + } + } + } + return result + }) + const shellModePart = createMemo(() => { const p = parts() if (!p.every((part) => part?.type === "text" && part?.synthetic)) return @@ -469,6 +485,13 @@ export function SessionTurn(
+ 0}> +
+ + {({ part, message }) => } + +
+
{/* Summary */}
diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index c1a29cd04dc..7e90e9f2f32 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -92,6 +92,7 @@ export type ToastVariant = "default" | "success" | "error" | "loading" export interface ToastAction { label: string onClick: "dismiss" | (() => void) + dismissAfter?: boolean } export interface ToastOptions { @@ -128,7 +129,14 @@ export function showToast(options: ToastOptions | string) { {opts.actions!.map((action) => ( diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index f532534188c..3292ba579f0 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, FileDiff, SessionStatus } from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, FileDiff, SessionStatus, Permission } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -13,6 +13,9 @@ type Data = { session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] } + permission?: { + [sessionID: string]: Permission[] + } message: { [sessionID: string]: Message[] } @@ -21,9 +24,15 @@ type Data = { } } +export type PermissionRespondFn = (input: { + sessionID: string + permissionID: string + response: "once" | "always" | "reject" +}) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", - init: (props: { data: Data; directory: string }) => { + init: (props: { data: Data; directory: string; onPermissionRespond?: PermissionRespondFn }) => { return { get store() { return props.data @@ -31,6 +40,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ get directory() { return props.directory }, + respondToPermission: props.onPermissionRespond, } }, }) diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx index 56be9ee4789..8e1a6aad8e5 100644 --- a/packages/ui/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -33,10 +33,6 @@ function init() { }, close() { active()?.onClose?.() - if (!active()?.onClose) { - const promptInput = document.querySelector("[data-component=prompt-input]") as HTMLElement - promptInput?.focus() - } setActive(undefined) }, show(element: DialogElement, owner: Owner, onClose?: () => void) { From 7aecb43e846eea4c383a29bf6a224db909ac474a Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 27 Dec 2025 20:51:09 +0000 Subject: [PATCH 046/101] release: v1.0.204 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 16 +++++++------- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 46 insertions(+), 46 deletions(-) diff --git a/bun.lock b/bun.lock index 593033e34f4..c2827775623 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -98,7 +98,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -125,7 +125,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -149,7 +149,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -173,7 +173,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/app": "workspace:*", "@solid-primitives/storage": "catalog:", @@ -200,7 +200,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -229,7 +229,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -245,7 +245,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.203", + "version": "1.0.204", "bin": { "opencode": "./bin/opencode", }, @@ -347,7 +347,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -367,7 +367,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.203", + "version": "1.0.204", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -378,7 +378,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -391,7 +391,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -426,7 +426,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "zod": "catalog:", }, @@ -437,7 +437,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 4fc9678e70e..7937753aea1 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.203", + "version": "1.0.204", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index a12dc87f24d..4474366b880 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4f6d2717fb7..f74d28b2e32 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.203", + "version": "1.0.204", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 572a86ddd5e..57b004fb709 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.203", + "version": "1.0.204", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1b2869dd9ec..f2c7c7302f9 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.203", + "version": "1.0.204", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4bdb5ce3886..23aa11091fb 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo -b", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index a89e5df7ef7..e4a7f45beae 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.203", + "version": "1.0.204", "private": true, "type": "module", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index e21818e4629..3e01e835339 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.203" +version = "1.0.204" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.203/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.204/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 160e78b35fd..44c6ef110ef 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.203", + "version": "1.0.204", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 55656660e06..67c8443cbb7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.203", + "version": "1.0.204", "name": "opencode", "type": "module", "private": true, @@ -52,22 +52,22 @@ "@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/azure": "2.0.73", + "@ai-sdk/cerebras": "1.0.33", + "@ai-sdk/cohere": "2.0.21", + "@ai-sdk/deepinfra": "1.0.30", + "@ai-sdk/gateway": "2.0.23", "@ai-sdk/google": "2.0.44", "@ai-sdk/google-vertex": "3.0.81", + "@ai-sdk/groq": "2.0.33", "@ai-sdk/mcp": "0.0.8", "@ai-sdk/mistral": "2.0.26", "@ai-sdk/openai": "2.0.71", "@ai-sdk/openai-compatible": "1.0.27", + "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", - "@ai-sdk/xai": "2.0.42", - "@ai-sdk/cerebras": "1.0.33", - "@ai-sdk/cohere": "2.0.21", - "@ai-sdk/deepinfra": "1.0.30", - "@ai-sdk/gateway": "2.0.23", - "@ai-sdk/groq": "2.0.33", - "@ai-sdk/perplexity": "2.0.22", "@ai-sdk/togetherai": "1.0.30", + "@ai-sdk/xai": "2.0.42", "@clack/prompts": "1.0.0-alpha.1", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 63abdf3e471..4d82f2a5fd8 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": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 292e8163acd..ac6f8480269 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": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 4c2f8eb7356..98cb0d7e7d6 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/ui/package.json b/packages/ui/package.json index 0e7da54bdcb..bb6adb0fb76 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.203", + "version": "1.0.204", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/util/package.json b/packages/util/package.json index c5df6f176bc..f558fdc01c0 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.203", + "version": "1.0.204", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 2fb471239b7..866eaab394a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.203", + "version": "1.0.204", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 1b4cf99f985..5d15e76c2eb 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": "1.0.203", + "version": "1.0.204", "publisher": "sst-dev", "repository": { "type": "git", From 7617f594412acd3545bce6f6dd07f86d36b25560 Mon Sep 17 00:00:00 2001 From: ewired <37567272+ewired@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:53:17 -0500 Subject: [PATCH 047/101] Allow line numbers and ranges in autocomplete (#4238) --- .../cmd/tui/component/prompt/autocomplete.tsx | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index cef083ad734..a5823289505 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" +function removeLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + return hashIndex !== -1 ? input.substring(0, hashIndex) : input +} + +function extractLineRange(input: string) { + const hashIndex = input.lastIndexOf("#") + if (hashIndex === -1) { + return { baseQuery: input } + } + + const baseName = input.substring(0, hashIndex) + const linePart = input.substring(hashIndex + 1) + const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/) + + if (!lineMatch) { + return { baseQuery: baseName } + } + + const startLine = Number(lineMatch[1]) + const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined + + return { + lineRange: { + baseName, + startLine, + endLine, + }, + baseQuery: baseName, + } +} + export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void @@ -142,9 +174,11 @@ export function Autocomplete(props: { async (query) => { if (!store.visible || store.visible === "/") return [] + const { lineRange, baseQuery } = extractLineRange(query ?? "") + // Get files from SDK const result = await sdk.client.find.files({ - query: query ?? "", + query: baseQuery, }) const options: AutocompleteOption[] = [] @@ -153,15 +187,27 @@ export function Autocomplete(props: { if (!result.error && result.data) { const width = props.anchor().width - 4 options.push( - ...result.data.map( - (item): AutocompleteOption => ({ - display: Locale.truncateMiddle(item, width), + ...result.data.map((item): AutocompleteOption => { + let url = `file://${process.cwd()}/${item}` + let filename = item + if (lineRange && !item.endsWith("/")) { + filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + const urlObj = new URL(url) + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) + } + url = urlObj.toString() + } + + return { + display: Locale.truncateMiddle(filename, width), onSelect: () => { - insertPart(item, { + insertPart(filename, { type: "file", mime: "text/plain", - filename: item, - url: `file://${process.cwd()}/${item}`, + filename, + url, source: { type: "file", text: { @@ -173,8 +219,8 @@ export function Autocomplete(props: { }, }) }, - }), - ), + } + }), ) } @@ -383,8 +429,8 @@ export function Autocomplete(props: { return prev } - const result = fuzzysort.go(currentFilter, mixed, { - keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], + const result = fuzzysort.go(removeLineRange(currentFilter), mixed, { + keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""], limit: 10, scoreFn: (objResults) => { const displayResult = objResults[0] From 613813ac12fa17cfae9b3b26d7b0d2105e273a63 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 28 Dec 2025 00:53:48 +0000 Subject: [PATCH 048/101] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 4d82f2a5fd8..63174acec23 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -24,4 +24,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index ac6f8480269..7933647718b 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -29,4 +29,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 9d485dd307ebced056729ae56903064c66cff85c Mon Sep 17 00:00:00 2001 From: Ivan Pantic Date: Sun, 28 Dec 2025 01:54:27 +0100 Subject: [PATCH 049/101] docs: add opencode-notificator to ecosystem plugins list (#6269) Co-authored-by: Ivan Pantic Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 9c772b99305..de0d5fd3769 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -32,6 +32,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | | [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | +| [opencode-notificator](https://github.com/panta/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions | | [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context | --- From de28fafb471cca4a79be2b9e0b8767ec852ea5ab Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 27 Dec 2025 19:07:25 -0600 Subject: [PATCH 050/101] fix: search all recent models instead of only top 5 in TUI /models command --- .../src/cli/cmd/tui/component/dialog-model.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index fc0559cd686..bc90dbb5c6e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) { const recents = local.model.recent() const recentList = showExtra() - ? recents - .filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ) - .slice(0, 5) + ? recents.filter( + (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), + ) : [] const favoriteOptions = favorites.flatMap((item) => { @@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) { // Apply fuzzy filtering to each section separately, maintaining section order if (q) { const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) - const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj) + const filteredRecents = fuzzysort + .go(q, recentOptions, { keys: ["title"] }) + .map((x) => x.obj) + .slice(0, 5) const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] From 7a94d7a2c5a12c9fbda987b8c63dddd5df3f1393 Mon Sep 17 00:00:00 2001 From: processtrader <232431073+processtrader@users.noreply.github.com> Date: Sun, 28 Dec 2025 02:10:23 +0100 Subject: [PATCH 051/101] fix: stats command to correctly handle `--days 0` for current day statistics (#6259) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node --- packages/opencode/src/cli/cmd/stats.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index f41b23ee971..94f1b549f40 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -82,12 +82,21 @@ async function getAllSessions(): Promise { return sessions } -async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { +export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { const sessions = await getAllSessions() - const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 - const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0 + const MS_IN_DAY = 24 * 60 * 60 * 1000 + + const cutoffTime = (() => { + if (days === undefined) return 0 + if (days === 0) { + const now = new Date() + now.setHours(0, 0, 0, 0) + return now.getTime() + } + return Date.now() - days * MS_IN_DAY + })() - let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions + let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions if (projectFilter !== undefined) { if (projectFilter === "") { @@ -198,7 +207,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro } } - const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND)) + const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) stats.dateRange = { earliest: earliestTime, latest: latestTime, From 8a2f4ddf70813aa37c69810d5c5a96a1388dd6fa Mon Sep 17 00:00:00 2001 From: Connor Adams Date: Sun, 28 Dec 2025 01:10:51 +0000 Subject: [PATCH 052/101] chore: update `INVALID_DIRS` to include plural 'skills' directory (#6255) --- packages/opencode/src/config/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c94a34be0e6..807cd46fd26 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -155,7 +155,7 @@ export namespace Config { } }) - const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools"].join(",")}}/`) + const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`) async function assertValid(dir: string) { const invalid = await Array.fromAsync( INVALID_DIRS.scan({ From 2fe7a7f2d32e8f61d1e88e2bd2966b66ee9a9cd3 Mon Sep 17 00:00:00 2001 From: Nindaleth Date: Sun, 28 Dec 2025 02:11:30 +0100 Subject: [PATCH 053/101] docs: document attach command (#6254) Co-authored-by: Black_Fox --- packages/web/src/content/docs/cli.mdx | 29 ++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4a826e5b3ff..35ef993b8ec 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -57,6 +57,33 @@ opencode agent [command] --- +### attach + +Attach a terminal to an already running OpenCode backend server started via `serve` or `web` commands. + +```bash +opencode attach [url] +``` + +This allows using the TUI with a remote OpenCode backend. For example: + +```bash +# Start the backend server for web/mobile access +opencode web --port 4096 --hostname 0.0.0.0 + +# In another terminal, attach the TUI to the running backend +opencode attach http://10.20.30.40:4096 +``` + +#### Flags + +| Flag | Short | Description | +| ------------ | ----- | --------------------------------- | +| `--dir` | | Working directory to start TUI in | +| `--session` | `-s` | Session ID to continue | + +--- + #### create Create a new agent with custom configuration. @@ -325,7 +352,7 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" ### serve -Start a headless opencode server for API access. Check out the [server docs](/docs/server) for the full HTTP interface. +Start a headless OpenCode server for API access. Check out the [server docs](/docs/server) for the full HTTP interface. ```bash opencode serve From 2c0d9a46cbd394b5f50e12e6a4a03928307607c2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 28 Dec 2025 01:12:02 +0000 Subject: [PATCH 054/101] chore: generate --- packages/web/src/content/docs/cli.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 35ef993b8ec..1553dc80ee9 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -77,10 +77,10 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ------------ | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ----------- | ----- | --------------------------------- | +| `--dir` | | Working directory to start TUI in | +| `--session` | `-s` | Session ID to continue | --- From e35d97f9d7005a4227eb56cc008cffb230161eda Mon Sep 17 00:00:00 2001 From: scarf Date: Sun, 28 Dec 2025 10:14:56 +0900 Subject: [PATCH 055/101] feat: add bash shell completions (#6239) --- packages/opencode/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 638ee7347db..03ccf76042f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -77,6 +77,7 @@ const cli = yargs(hideBin(process.argv)) }) }) .usage("\n" + UI.logo()) + .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) From 7ea0d37ee3b01be8788a95db5b6f08690d01465c Mon Sep 17 00:00:00 2001 From: rektide Date: Sun, 28 Dec 2025 01:32:33 +0000 Subject: [PATCH 056/101] Thinking & tool call visibility settings for `/copy` and `/export` (#6243) Co-authored-by: Aiden Cline --- .../src/cli/cmd/tui/routes/session/index.tsx | 51 ++++-- .../cli/cmd/tui/ui/dialog-export-options.tsx | 148 ++++++++++++++++++ 2 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 177c43a463a..d5298518700 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" -import { DialogPrompt } from "@tui/ui/dialog-prompt" import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" @@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" -import { DialogSubagent } from "./dialog-subagent.tsx" +import { DialogExportOptions } from "../../ui/dialog-export-options" addDefaultParsers(parsers.parsers) @@ -784,8 +783,22 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (showThinking()) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (showDetails() && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (showDetails() && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (showDetails() && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } @@ -812,6 +825,14 @@ export function Session() { const sessionData = session() const sessionMessages = messages() + const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md` + + const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails()) + + if (options === null) return + + const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options + let transcript = `# ${sessionData.title}\n\n` transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` @@ -826,22 +847,28 @@ export function Session() { for (const part of parts) { if (part.type === "text" && !part.synthetic) { transcript += `${part.text}\n\n` + } else if (part.type === "reasoning") { + if (includeThinking) { + transcript += `_Thinking:_\n\n${part.text}\n\n` + } } else if (part.type === "tool") { - transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` + transcript += `\`\`\`\nTool: ${part.tool}\n` + if (includeToolDetails && part.state.input) { + transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "completed" && part.state.output) { + transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`` + } + if (includeToolDetails && part.state.status === "error" && part.state.error) { + transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`` + } + transcript += `\n\`\`\`\n\n` } } transcript += `---\n\n` } - // Prompt for optional filename - const customFilename = await DialogPrompt.show(dialog, "Export filename", { - value: `session-${sessionData.id.slice(0, 8)}.md`, - }) - - // Cancel if user pressed escape - if (customFilename === null) return - // Save to file in current working directory const exportDir = process.cwd() const filename = customFilename.trim() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx new file mode 100644 index 00000000000..874a236ee4c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -0,0 +1,148 @@ +import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { useDialog, type DialogContext } from "./dialog" +import { createStore } from "solid-js/store" +import { onMount, Show, type JSX } from "solid-js" +import { useKeyboard } from "@opentui/solid" + +export type DialogExportOptionsProps = { + defaultFilename: string + defaultThinking: boolean + defaultToolDetails: boolean + onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void + onCancel?: () => void +} + +export function DialogExportOptions(props: DialogExportOptionsProps) { + const dialog = useDialog() + const { theme } = useTheme() + let textarea: TextareaRenderable + const [store, setStore] = createStore({ + thinking: props.defaultThinking, + toolDetails: props.defaultToolDetails, + active: "filename" as "filename" | "thinking" | "toolDetails", + }) + + useKeyboard((evt) => { + if (evt.name === "return") { + props.onConfirm?.({ + filename: textarea.plainText, + thinking: store.thinking, + toolDetails: store.toolDetails, + }) + } + if (evt.name === "tab") { + const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"] + const currentIndex = order.indexOf(store.active) + const nextIndex = (currentIndex + 1) % order.length + setStore("active", order[nextIndex]) + evt.preventDefault() + } + if (evt.name === "space") { + if (store.active === "thinking") setStore("thinking", !store.thinking) + if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) + evt.preventDefault() + } + }) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + textarea.focus() + }, 1) + textarea.gotoLineEnd() + }) + + return ( + + + + Export Options + + esc + + + + Filename: + +