From 3db37c51ccd23a0b9314cac788c3b372f4df07aa Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:28:42 -0800 Subject: [PATCH 01/13] feat: implement Kilo Code device authorization flow and provider integration --- packages/opencode/src/cli/cmd/auth.ts | 79 +++++++++++++++++++++- packages/opencode/src/provider/provider.ts | 73 +++++++++++++++----- sst.config.ts | 12 ++-- 3 files changed, 139 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index ae24fbef5996..40c0ccc40964 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -9,13 +9,14 @@ import os from "os" import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" +import open from "open" export const AuthCommand = cmd({ command: "auth", describe: "manage credentials", builder: (yargs) => yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), - async handler() {}, + async handler() { }, }) export const AuthListCommand = cmd({ @@ -30,6 +31,14 @@ export const AuthListCommand = cmd({ prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) const results = await Auth.all().then((x) => Object.entries(x)) const database = await ModelsDev.get() + if (!database["kilocode"]) { + database["kilocode"] = { + id: "kilocode", + name: "Kilo Code", + env: ["KILOCODE_API_KEY"], + models: {}, + } + } for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -102,7 +111,7 @@ export const AuthLoginCommand = cmd({ prompts.outro("Done") return } - await ModelsDev.refresh().catch(() => {}) + await ModelsDev.refresh().catch(() => { }) const providers = await ModelsDev.get() const priority: Record = { opencode: 0, @@ -112,6 +121,7 @@ export const AuthLoginCommand = cmd({ google: 4, openrouter: 5, vercel: 6, + kilocode: 7, } let provider = await prompts.autocomplete({ message: "Select provider", @@ -130,6 +140,10 @@ export const AuthLoginCommand = cmd({ hint: priority[x.id] <= 1 ? "recommended" : undefined, })), ), + { + label: "Kilo Code", + value: "kilocode", + }, { value: "other", label: "Other", @@ -139,6 +153,67 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() + if (provider === "kilocode") { + const spinner = prompts.spinner() + spinner.start("Initiating Kilo Code authorization...") + + try { + const response = await fetch("https://api.kilo.ai/api/device-auth/codes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + + if (!response.ok) throw new Error(`Failed to initiate auth: ${response.status}`) + + const { code, verificationUrl, expiresIn } = (await response.json()) as any + spinner.stop(`Code: ${UI.Style.TEXT_NORMAL_BOLD}${code}`) + + prompts.log.info(`Go to: ${UI.Style.TEXT_NORMAL_BOLD}${verificationUrl}`) + await open(verificationUrl) + + const pollSpinner = prompts.spinner() + pollSpinner.start("Waiting for authorization...") + + const start = Date.now() + while (Date.now() - start < expiresIn * 1000) { + const pollResponse = await fetch(`https://api.kilo.ai/api/device-auth/codes/${code}`) + + if (pollResponse.status === 200) { + const data = (await pollResponse.json()) as any + if (data.status === "approved" && data.token) { + await Auth.set("kilocode", { + type: "api", + key: data.token, + }) + pollSpinner.stop(`Login successful as ${data.userEmail}`) + prompts.outro("Done") + return + } + } + + if (pollResponse.status === 403) { + pollSpinner.stop("Authorization denied", 1) + prompts.outro("Done") + return + } + + if (pollResponse.status === 410) { + pollSpinner.stop("Authorization expired", 1) + prompts.outro("Done") + return + } + + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + + pollSpinner.stop("Authorization timed out", 1) + } catch (e) { + spinner.stop(`Error: ${e instanceof Error ? e.message : String(e)}`, 1) + } + prompts.outro("Done") + return + } + const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { let index = 0 diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 774860ffb567..0ced57dafd5d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -180,6 +180,19 @@ export namespace Provider { }, } }, + kilocode: async (provider) => { + if (provider) provider.npm = "@ai-sdk/openrouter" + return { + autoload: true, + options: { + baseURL: "https://api.kilo.ai/api/openrouter/v1", + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + } + }, vercel: async () => { return { autoload: false, @@ -242,7 +255,33 @@ export namespace Provider { using _ = log.time("state") const config = await Config.get() const database = await ModelsDev.get() - + if (!database["kilocode"]) { + database["kilocode"] = { + id: "kilocode", + name: "Kilo Code", + env: ["KILOCODE_API_KEY"], + models: { + "anthropic/claude-3-5-sonnet": { + id: "anthropic/claude-3-5-sonnet", + name: "Claude 3.5 Sonnet", + release_date: "2024-06-20", + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + cost: { + input: 0, + output: 0, + }, + limit: { + context: 200000, + output: 8192, + }, + options: {}, + }, + }, + } + } const providers: { [providerID: string]: { source: Source @@ -334,31 +373,31 @@ export namespace Provider { cost: !model.cost && !existing?.cost ? { - input: 0, - output: 0, - cache_read: 0, - cache_write: 0, - } + input: 0, + output: 0, + cache_read: 0, + cache_write: 0, + } : { - cache_read: 0, - cache_write: 0, - ...existing?.cost, - ...model.cost, - }, + cache_read: 0, + cache_write: 0, + ...existing?.cost, + ...model.cost, + }, options: { ...existing?.options, ...model.options, }, limit: model.limit ?? existing?.limit ?? { - context: 0, - output: 0, - }, + context: 0, + output: 0, + }, modalities: model.modalities ?? existing?.modalities ?? { - input: ["text"], - output: ["text"], - }, + input: ["text"], + output: ["text"], + }, headers: model.headers, provider: model.provider ?? existing?.provider, } diff --git a/sst.config.ts b/sst.config.ts index a18787391200..bfc87a9fa961 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -1,4 +1,4 @@ -/// +import "./.sst/platform/config.d.ts"; export default $config({ app(input) { @@ -13,11 +13,11 @@ export default $config({ }, planetscale: "0.4.1", }, - } + }; }, async run() { - await import("./infra/app.js") - await import("./infra/console.js") - await import("./infra/desktop.js") + await import("./infra/app.js"); + await import("./infra/console.js"); + await import("./infra/desktop.js"); }, -}) +}); From 88f7f70228a4ac78e22e336ba9e064174bcec053 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:32:07 -0800 Subject: [PATCH 02/13] refactor: move Kilo Code provider registration to ModelsDev for cleaner integration --- packages/opencode/src/cli/cmd/auth.ts | 12 -------- packages/opencode/src/provider/models.ts | 36 +++++++++++++++++++--- packages/opencode/src/provider/provider.ts | 27 ---------------- 3 files changed, 32 insertions(+), 43 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 40c0ccc40964..6cf7b99edf7e 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -31,14 +31,6 @@ export const AuthListCommand = cmd({ prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) const results = await Auth.all().then((x) => Object.entries(x)) const database = await ModelsDev.get() - if (!database["kilocode"]) { - database["kilocode"] = { - id: "kilocode", - name: "Kilo Code", - env: ["KILOCODE_API_KEY"], - models: {}, - } - } for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -140,10 +132,6 @@ export const AuthLoginCommand = cmd({ hint: priority[x.id] <= 1 ? "recommended" : undefined, })), ), - { - label: "Kilo Code", - value: "kilocode", - }, { value: "other", label: "Other", diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 676837e1521f..c53421e11c24 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -71,10 +71,38 @@ export namespace ModelsDev { export async function get() { refresh() const file = Bun.file(filepath) - const result = await file.json().catch(() => {}) - if (result) return result as Record - const json = await data() - return JSON.parse(json) as Record + const result = (await file.json().catch(() => { })) || JSON.parse(await data()) + const database = result as Record + + if (!database["kilocode"]) { + database["kilocode"] = { + id: "kilocode", + name: "Kilo Code", + env: ["KILOCODE_API_KEY"], + models: { + "anthropic/claude-3-5-sonnet": { + id: "anthropic/claude-3-5-sonnet", + name: "Claude 3.5 Sonnet", + release_date: "2024-06-20", + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + cost: { + input: 0, + output: 0, + }, + limit: { + context: 200000, + output: 8192, + }, + options: {}, + }, + }, + } + } + + return database } export async function refresh() { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0ced57dafd5d..9869148bcce0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -255,33 +255,6 @@ export namespace Provider { using _ = log.time("state") const config = await Config.get() const database = await ModelsDev.get() - if (!database["kilocode"]) { - database["kilocode"] = { - id: "kilocode", - name: "Kilo Code", - env: ["KILOCODE_API_KEY"], - models: { - "anthropic/claude-3-5-sonnet": { - id: "anthropic/claude-3-5-sonnet", - name: "Claude 3.5 Sonnet", - release_date: "2024-06-20", - attachment: true, - reasoning: false, - temperature: true, - tool_call: true, - cost: { - input: 0, - output: 0, - }, - limit: { - context: 200000, - output: 8192, - }, - options: {}, - }, - }, - } - } const providers: { [providerID: string]: { source: Source From 4a163b55bef0f271aafaa15e45b1a3d5033f8cf4 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:03:54 -0800 Subject: [PATCH 03/13] feat: implement dynamic model discovery for Kilo Code provider --- packages/opencode/src/provider/models.ts | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c53421e11c24..418e1aee9081 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -4,6 +4,7 @@ import path from "path" import z from "zod" import { data } from "./models-macro" with { type: "macro" } import { Installation } from "../installation" +import { Auth } from "../auth" export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) @@ -102,6 +103,46 @@ export namespace ModelsDev { } } + const auth = await Auth.get("kilocode") + if (auth && auth.type === "api") { + try { + const response = await fetch("https://api.kilo.ai/api/openrouter/models", { + headers: { + Authorization: `Bearer ${auth.key}`, + }, + signal: AbortSignal.timeout(5000), + }) + if (response.ok) { + const json = (await response.json()) as any + const models = json.data + if (Array.isArray(models)) { + for (const model of models) { + database["kilocode"].models[model.id] = { + id: model.id, + name: model.name, + release_date: "2024-01-01", + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + cost: { + input: 0, + output: 0, + }, + limit: { + context: model.context_length || 128000, + output: 4096, + }, + options: {}, + } + } + } + } + } catch (e) { + log.error("Failed to discover kilocode models", { error: e }) + } + } + return database } From b8a0f10c232639d4c9db46764da37884affaf5ed Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:28:18 -0800 Subject: [PATCH 04/13] feat: enhance kilocode provider with caching and improved model discovery --- packages/opencode/src/provider/models.ts | 90 ++++++++++++++-------- packages/opencode/src/provider/provider.ts | 10 ++- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 418e1aee9081..36bfbb72a195 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -69,6 +69,9 @@ export namespace ModelsDev { export type Provider = z.infer + const cacheDir = Global.Path.cache + const kilocodeFilepath = path.join(cacheDir, "kilocode-models.json") + export async function get() { refresh() const file = Bun.file(filepath) @@ -103,43 +106,68 @@ export namespace ModelsDev { } } + // Try to load from local cache first + const kilocodeCache = await Bun.file(kilocodeFilepath) + .json() + .catch(() => null) + if (kilocodeCache) { + Object.assign(database["kilocode"].models, kilocodeCache) + } + const auth = await Auth.get("kilocode") if (auth && auth.type === "api") { - try { - const response = await fetch("https://api.kilo.ai/api/openrouter/models", { - headers: { - Authorization: `Bearer ${auth.key}`, - }, - signal: AbortSignal.timeout(5000), - }) - if (response.ok) { - const json = (await response.json()) as any - const models = json.data - if (Array.isArray(models)) { - for (const model of models) { - database["kilocode"].models[model.id] = { - id: model.id, - name: model.name, - release_date: "2024-01-01", - attachment: true, - reasoning: false, - temperature: true, - tool_call: true, - cost: { - input: 0, - output: 0, - }, - limit: { - context: model.context_length || 128000, - output: 4096, - }, - options: {}, + const refreshModels = async () => { + try { + const response = await fetch("https://api.kilo.ai/api/openrouter/models", { + headers: { + Authorization: `Bearer ${auth.key}`, + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KiloCode-Version": "4.138.0", + "User-Agent": "Kilo-Code/4.138.0", + }, + signal: AbortSignal.timeout(5000), + }) + if (response.ok) { + const json = (await response.json()) as any + const models = json.data + if (Array.isArray(models)) { + const newModels: Record = {} + for (const model of models) { + newModels[model.id] = { + id: model.id, + name: model.name, + release_date: "2024-01-01", + attachment: true, + reasoning: model.supported_parameters?.includes("reasoning") ?? false, + temperature: model.supported_parameters?.includes("temperature") ?? true, + tool_call: model.supported_parameters?.includes("tools") ?? true, + cost: { + input: parseFloat(model.pricing?.prompt || "0"), + output: parseFloat(model.pricing?.completion || "0"), + }, + limit: { + context: model.context_length || 128000, + output: model.top_provider?.max_completion_tokens || 4096, + }, + options: {}, + } } + Object.assign(database["kilocode"].models, newModels) + await Bun.write(kilocodeFilepath, JSON.stringify(newModels, null, 2)) } } + } catch (e) { + log.error("Failed to discover kilocode models", { error: e }) } - } catch (e) { - log.error("Failed to discover kilocode models", { error: e }) + } + + if (!kilocodeCache) { + // Block if no cache exists yet + await refreshModels() + } else { + // Refresh in background if we have a cache + refreshModels() } } diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9869148bcce0..0a3f2f5fb885 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -181,14 +181,16 @@ export namespace Provider { } }, kilocode: async (provider) => { - if (provider) provider.npm = "@ai-sdk/openrouter" + if (provider) provider.npm = "@ai-sdk/openai-compatible" return { autoload: true, options: { - baseURL: "https://api.kilo.ai/api/openrouter/v1", + baseURL: "https://api.kilo.ai/api/openrouter", headers: { - "HTTP-Referer": "https://opencode.ai/", - "X-Title": "opencode", + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KiloCode-Version": "4.138.0", + "User-Agent": "Kilo-Code/4.138.0", }, }, } From 9910fb76dd33ded7354bf6027b14cd94fa5c63bb Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:34:01 -0800 Subject: [PATCH 05/13] Use mimimax-m2 --- a.out | 0 packages/opencode/src/provider/models.ts | 12 ++++++------ 2 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 a.out diff --git a/a.out b/a.out deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 36bfbb72a195..34d1ef35254d 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -75,7 +75,7 @@ export namespace ModelsDev { export async function get() { refresh() const file = Bun.file(filepath) - const result = (await file.json().catch(() => { })) || JSON.parse(await data()) + const result = (await file.json().catch(() => {})) || JSON.parse(await data()) const database = result as Record if (!database["kilocode"]) { @@ -84,10 +84,10 @@ export namespace ModelsDev { name: "Kilo Code", env: ["KILOCODE_API_KEY"], models: { - "anthropic/claude-3-5-sonnet": { - id: "anthropic/claude-3-5-sonnet", - name: "Claude 3.5 Sonnet", - release_date: "2024-06-20", + "minimax/max-m2": { + id: "minimax/max-m2", + name: "Minimax M2", + release_date: "2024-12-01", attachment: true, reasoning: false, temperature: true, @@ -97,7 +97,7 @@ export namespace ModelsDev { output: 0, }, limit: { - context: 200000, + context: 128000, output: 8192, }, options: {}, From cac4113346a405f2f9e7fe9a0c955903ba4b5a3d Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:15:10 -0800 Subject: [PATCH 06/13] feat: add install:local script for developers --- install_local.sh | 45 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 ++- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 install_local.sh diff --git a/install_local.sh b/install_local.sh new file mode 100644 index 000000000000..0331c49a17f0 --- /dev/null +++ b/install_local.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -e + +# 1. Build the local binary +echo "๐Ÿ”จ Building opencode from source..." +cd packages/opencode +export PATH="$HOME/.bun/bin:$PATH" +bun run build --single + +# 2. Setup the bin directory +INSTALL_DIR="$HOME/.opencode/bin" +mkdir -p "$INSTALL_DIR" + +# 3. Detect architecture and copy binary +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +if [[ "$OS" == "darwin" ]]; then + PLATFORM="darwin" +elif [[ "$OS" == "linux" ]]; then + PLATFORM="linux" +else + PLATFORM=$OS +fi + +ARCH=$(uname -m) +if [[ "$ARCH" == "x86_64" ]]; then + ARCH="x64" +elif [[ "$ARCH" == "aarch64" ]]; then + ARCH="arm64" +fi + +BINARY_DIR="opencode-$PLATFORM-$ARCH" +echo "๐Ÿ“ฆ Copying binary from dist/$BINARY_DIR..." +cp "dist/$BINARY_DIR/bin/opencode" "$INSTALL_DIR/opencode" +chmod +x "$INSTALL_DIR/opencode" + +echo "โœ… Installed to $INSTALL_DIR/opencode" + +# 4. Check PATH +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo "โš ๏ธ $INSTALL_DIR is not in your PATH." + echo "Add this to your .zshrc or .bashrc:" + echo "export PATH=\"\$HOME/.opencode/bin:\$PATH\"" +else + echo "๐Ÿš€ You're all set! Run 'opencode' to start." +fi diff --git a/package.json b/package.json index 13df96d39755..da7e05b73ed2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", "prepare": "husky", + "install:local": "./install_local.sh", "random": "echo 'Random script'" }, "workspaces": { @@ -83,4 +84,4 @@ "@types/bun": "catalog:", "@types/node": "catalog:" } -} +} \ No newline at end of file From 39459821634701ed39583e59d5d4d7d6d6924e09 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 15:16:08 -0800 Subject: [PATCH 07/13] feat: integrate local installation into build script --- install_local.sh | 45 ------------ package.json | 2 +- packages/opencode/script/build.ts | 116 ++++++++++++++++-------------- 3 files changed, 65 insertions(+), 98 deletions(-) delete mode 100644 install_local.sh diff --git a/install_local.sh b/install_local.sh deleted file mode 100644 index 0331c49a17f0..000000000000 --- a/install_local.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -e - -# 1. Build the local binary -echo "๐Ÿ”จ Building opencode from source..." -cd packages/opencode -export PATH="$HOME/.bun/bin:$PATH" -bun run build --single - -# 2. Setup the bin directory -INSTALL_DIR="$HOME/.opencode/bin" -mkdir -p "$INSTALL_DIR" - -# 3. Detect architecture and copy binary -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -if [[ "$OS" == "darwin" ]]; then - PLATFORM="darwin" -elif [[ "$OS" == "linux" ]]; then - PLATFORM="linux" -else - PLATFORM=$OS -fi - -ARCH=$(uname -m) -if [[ "$ARCH" == "x86_64" ]]; then - ARCH="x64" -elif [[ "$ARCH" == "aarch64" ]]; then - ARCH="arm64" -fi - -BINARY_DIR="opencode-$PLATFORM-$ARCH" -echo "๐Ÿ“ฆ Copying binary from dist/$BINARY_DIR..." -cp "dist/$BINARY_DIR/bin/opencode" "$INSTALL_DIR/opencode" -chmod +x "$INSTALL_DIR/opencode" - -echo "โœ… Installed to $INSTALL_DIR/opencode" - -# 4. Check PATH -if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then - echo "โš ๏ธ $INSTALL_DIR is not in your PATH." - echo "Add this to your .zshrc or .bashrc:" - echo "export PATH=\"\$HOME/.opencode/bin:\$PATH\"" -else - echo "๐Ÿš€ You're all set! Run 'opencode' to start." -fi diff --git a/package.json b/package.json index da7e05b73ed2..efbc74436a86 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", "prepare": "husky", - "install:local": "./install_local.sh", + "install:local": "bun run --cwd packages/opencode build --single --install", "random": "echo 'Random script'" }, "workspaces": { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 502baed02384..a9f67e55a1f0 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -23,58 +23,58 @@ const allTargets: { abi?: "musl" avx2?: false }[] = [ - { - os: "linux", - arch: "arm64", - }, - { - os: "linux", - arch: "x64", - }, - { - os: "linux", - arch: "x64", - avx2: false, - }, - { - os: "linux", - arch: "arm64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - }, - { - os: "linux", - arch: "x64", - abi: "musl", - avx2: false, - }, - { - os: "darwin", - arch: "arm64", - }, - { - os: "darwin", - arch: "x64", - }, - { - os: "darwin", - arch: "x64", - avx2: false, - }, - { - os: "win32", - arch: "x64", - }, - { - os: "win32", - arch: "x64", - avx2: false, - }, -] + { + os: "linux", + arch: "arm64", + }, + { + os: "linux", + arch: "x64", + }, + { + os: "linux", + arch: "x64", + avx2: false, + }, + { + os: "linux", + arch: "arm64", + abi: "musl", + }, + { + os: "linux", + arch: "x64", + abi: "musl", + }, + { + os: "linux", + arch: "x64", + abi: "musl", + avx2: false, + }, + { + os: "darwin", + arch: "arm64", + }, + { + os: "darwin", + arch: "x64", + }, + { + os: "darwin", + arch: "x64", + avx2: false, + }, + { + os: "win32", + arch: "x64", + }, + { + os: "win32", + arch: "x64", + avx2: false, + }, + ] const targets = singleFlag ? allTargets.filter((item) => item.os === process.platform && item.arch === process.arch) @@ -138,4 +138,16 @@ for (const item of targets) { binaries[name] = Script.version } +if (process.argv.includes("--install")) { + const os = process.platform === "win32" ? "windows" : process.platform + const arch = process.arch === "x64" ? "x64" : "arm64" + const name = [pkg.name, os, arch].join("-") + const binary = os === "windows" ? "opencode.exe" : "opencode" + const installDir = path.join(process.env.HOME || "", ".opencode", "bin") + await $`mkdir -p ${installDir}` + await $`cp dist/${name}/bin/${binary} ${installDir}/${binary}` + if (os !== "windows") await $`chmod +x ${installDir}/${binary}` + console.log(`โœ… Installed to ${installDir}/${binary}`) +} + export { binaries } From d339174b729a588aa839e3539bd1068cd01d3258 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:51:59 -0800 Subject: [PATCH 08/13] fix(kilocode): improve authentication flow and token refresh - Add JWT expiration check utility - Implement dynamic base URL resolution based on token environment - Add missing x-api-key and Authorization: Bearer headers - Add organizationId to ApiAuth schema - Add proactive token expiration warnings in CLI and model discovery --- packages/opencode/src/auth/index.ts | 1 + packages/opencode/src/cli/cmd/run.ts | 17 ++++++++++++++++- packages/opencode/src/provider/models.ts | 22 ++++++++++++++++++++-- packages/opencode/src/provider/provider.ts | 17 ++++++++++++++++- packages/opencode/src/util/jwt.ts | 15 +++++++++++++++ 5 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/util/jwt.ts diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 883b9acc68c4..6e155594ad8b 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -18,6 +18,7 @@ export namespace Auth { .object({ type: z.literal("api"), key: z.string(), + organizationId: z.string().optional(), }) .meta({ ref: "ApiAuth" }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index b646f0b15b74..030b5a018752 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -10,6 +10,7 @@ import { select } from "@clack/prompts" import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" +import { Auth } from "../../auth" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -88,6 +89,20 @@ export const RunCommand = cmd({ }) }, handler: async (args) => { + const { isJwtExpired } = await import("../../util/jwt") + const auth = await Auth.get("kilocode") + const defaultModel = await Provider.defaultModel().catch(() => undefined) + const isUsingKilocode = + args.model?.includes("kilocode") || + (!args.model && defaultModel?.providerID === "kilocode") + + if (isUsingKilocode && auth && auth.type === "api") { + if (isJwtExpired(auth.key)) { + UI.error("Kilo Code token has expired. Please run 'opencode auth login kilocode' to refresh it.") + process.exit(1) + } + } + let message = args.message.join(" ") const fileParts: any[] = [] @@ -97,7 +112,7 @@ export const RunCommand = cmd({ for (const filePath of files) { const resolvedPath = path.resolve(process.cwd(), filePath) const file = Bun.file(resolvedPath) - const stats = await file.stat().catch(() => {}) + const stats = await file.stat().catch(() => { }) if (!stats) { UI.error(`File not found: ${filePath}`) process.exit(1) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 34d1ef35254d..0e9be5efd808 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -75,7 +75,7 @@ export namespace ModelsDev { export async function get() { refresh() const file = Bun.file(filepath) - const result = (await file.json().catch(() => {})) || JSON.parse(await data()) + const result = (await file.json().catch(() => { })) || JSON.parse(await data()) const database = result as Record if (!database["kilocode"]) { @@ -116,11 +116,27 @@ export namespace ModelsDev { const auth = await Auth.get("kilocode") if (auth && auth.type === "api") { + const { isJwtExpired } = await import("../util/jwt") + if (isJwtExpired(auth.key)) { + log.warn("Kilo Code token has expired. Please run 'opencode auth login kilocode' to refresh it.") + } + const refreshModels = async () => { try { - const response = await fetch("https://api.kilo.ai/api/openrouter/models", { + const baseUrl = (() => { + try { + const parts = auth.key.split(".") + if (parts.length !== 3) return "https://api.kilo.ai" + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()) + if (payload.env === "development") return "http://localhost:3000" + } catch { } + return "https://api.kilo.ai" + })() + + const response = await fetch(`${baseUrl}/api/openrouter/models`, { headers: { Authorization: `Bearer ${auth.key}`, + "x-api-key": auth.key, "HTTP-Referer": "https://kilocode.ai", "X-Title": "Kilo Code", "X-KiloCode-Version": "4.138.0", @@ -156,6 +172,8 @@ export namespace ModelsDev { Object.assign(database["kilocode"].models, newModels) await Bun.write(kilocodeFilepath, JSON.stringify(newModels, null, 2)) } + } else if (response.status === 401 || response.status === 403) { + log.error("Kilo Code authentication failed. The token might be expired or invalid.") } } catch (e) { log.error("Failed to discover kilocode models", { error: e }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0a3f2f5fb885..2e8adc6d74c3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -181,12 +181,27 @@ export namespace Provider { } }, kilocode: async (provider) => { + const auth = await Auth.get("kilocode") + const key = auth && (auth.type === "api" || auth.type === "wellknown") ? auth.key : undefined + const baseUrl = (() => { + if (!key) return "https://api.kilo.ai" + try { + const parts = key.split(".") + if (parts.length !== 3) return "https://api.kilo.ai" + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()) + if (payload.env === "development") return "http://localhost:3000" + } catch { } + return "https://api.kilo.ai" + })() + if (provider) provider.npm = "@ai-sdk/openai-compatible" return { autoload: true, options: { - baseURL: "https://api.kilo.ai/api/openrouter", + baseURL: `${baseUrl}/api/openrouter`, headers: { + "Authorization": `Bearer ${key}`, + "x-api-key": key, "HTTP-Referer": "https://kilocode.ai", "X-Title": "Kilo Code", "X-KiloCode-Version": "4.138.0", diff --git a/packages/opencode/src/util/jwt.ts b/packages/opencode/src/util/jwt.ts new file mode 100644 index 000000000000..78093102a17e --- /dev/null +++ b/packages/opencode/src/util/jwt.ts @@ -0,0 +1,15 @@ +export function isJwtExpired(token: string): boolean { + try { + const parts = token.split(".") + if (parts.length !== 3) return false + + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()) + if (payload.exp) { + // Add a 30 second buffer + return Date.now() >= (payload.exp - 30) * 1000 + } + } catch (e) { + return true + } + return false +} From 8711dba8227fa788df063f0cc5446f3b97ec61ab Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:31:05 -0800 Subject: [PATCH 09/13] style: minor formatting cleanup in kilocode provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- packages/opencode/src/provider/models.ts | 2 +- packages/opencode/src/provider/provider.ts | 7 +++++-- packages/opencode/src/util/jwt.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 0e9be5efd808..0d523c11de0b 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -129,7 +129,7 @@ export namespace ModelsDev { if (parts.length !== 3) return "https://api.kilo.ai" const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()) if (payload.env === "development") return "http://localhost:3000" - } catch { } + } catch {} return "https://api.kilo.ai" })() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2e8adc6d74c3..9779c7c16d9e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -183,6 +183,8 @@ export namespace Provider { kilocode: async (provider) => { const auth = await Auth.get("kilocode") const key = auth && (auth.type === "api" || auth.type === "wellknown") ? auth.key : undefined + + // Determine base URL from token (dev vs prod) const baseUrl = (() => { if (!key) return "https://api.kilo.ai" try { @@ -190,17 +192,18 @@ export namespace Provider { if (parts.length !== 3) return "https://api.kilo.ai" const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()) if (payload.env === "development") return "http://localhost:3000" - } catch { } + } catch {} return "https://api.kilo.ai" })() if (provider) provider.npm = "@ai-sdk/openai-compatible" + return { autoload: true, options: { baseURL: `${baseUrl}/api/openrouter`, headers: { - "Authorization": `Bearer ${key}`, + Authorization: `Bearer ${key}`, "x-api-key": key, "HTTP-Referer": "https://kilocode.ai", "X-Title": "Kilo Code", diff --git a/packages/opencode/src/util/jwt.ts b/packages/opencode/src/util/jwt.ts index 78093102a17e..c39e0cfdc303 100644 --- a/packages/opencode/src/util/jwt.ts +++ b/packages/opencode/src/util/jwt.ts @@ -2,7 +2,7 @@ export function isJwtExpired(token: string): boolean { try { const parts = token.split(".") if (parts.length !== 3) return false - + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()) if (payload.exp) { // Add a 30 second buffer From 4ca7d6f79cd2f571e152446a21db2ef5f3fa02dd Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:18:04 -0800 Subject: [PATCH 10/13] fix: add stream idle timeout to prevent sessions from hanging indefinitely When network issues occur mid-stream (TCP half-open connections, stalled LLM providers, proxy timeouts), the streaming loop would hang forever waiting for the next chunk. This fix adds an idle timeout that detects when no data has been received for a configurable period (default 60 seconds) and triggers a retry with exponential backoff. Changes: - Add StreamIdleTimeoutError class to message-v2.ts - Add withIdleTimeout() async generator wrapper in processor.ts - Wrap stream.fullStream with idle timeout in the process loop - Mark StreamIdleTimeoutError as retryable so sessions recover automatically - Add stream_idle_timeout config option under experimental settings - Add additional network error types (ETIMEDOUT, ENOTFOUND, etc) as retryable Fixes issues where sessions get stuck for hours on LLM requests. Related: #8383, #2512, #2819, #4255 --- packages/opencode/src/config/config.ts | 6 ++ packages/opencode/src/session/message-v2.ts | 38 +++++++++++ packages/opencode/src/session/processor.ts | 70 ++++++++++++++++++++- 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e3079574..c2a22e469eb4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1095,6 +1095,12 @@ export namespace Config { .positive() .optional() .describe("Timeout in milliseconds for model context protocol (MCP) requests"), + stream_idle_timeout: z + .number() + .int() + .positive() + .optional() + .describe("Timeout in milliseconds between stream chunks from LLM. If no data is received within this period, the request will be retried. Default is 60000 (60 seconds). Set to 0 to disable."), }) .optional(), }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6358c6c5e9b0..d35b3972bf91 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -13,6 +13,17 @@ import { iife } from "@/util/iife" import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" +/** + * Error thrown when no data is received from the LLM stream within the timeout period. + * This typically indicates a stalled connection (network issues, LLM provider unresponsive). + */ +export class StreamIdleTimeoutError extends Error { + constructor(public readonly timeoutMs: number) { + super(`Stream idle timeout: no data received for ${timeoutMs}ms`) + this.name = "StreamIdleTimeoutError" + } +} + export namespace MessageV2 { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) @@ -697,6 +708,33 @@ export namespace MessageV2 { }, { cause: e }, ).toObject() + case e instanceof StreamIdleTimeoutError: + return new MessageV2.APIError( + { + message: e.message, + isRetryable: true, + metadata: { + timeoutMs: String(e.timeoutMs), + }, + }, + { cause: e }, + ).toObject() + // Handle additional network errors that indicate transient connection issues + case ["ETIMEDOUT", "ENOTFOUND", "ECONNREFUSED", "EPIPE", "EHOSTUNREACH", "ENETUNREACH"].includes( + (e as SystemError)?.code ?? "" + ): + return new MessageV2.APIError( + { + message: `Network error: ${(e as SystemError).code}`, + isRetryable: true, + metadata: { + code: (e as SystemError).code ?? "", + syscall: (e as SystemError).syscall ?? "", + message: (e as SystemError).message ?? "", + }, + }, + { cause: e }, + ).toObject() case APICallError.isInstance(e): const message = iife(() => { let msg = e.message diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 27071056180a..2f099979dce8 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { MessageV2 } from "./message-v2" +import { MessageV2, StreamIdleTimeoutError } from "./message-v2" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." @@ -16,6 +16,62 @@ import { SessionCompaction } from "./compaction" import { PermissionNext } from "@/permission/next" import { Question } from "@/question" +/** + * Wraps an async iterable with an idle timeout. If no value is yielded within + * the timeout period, throws a StreamIdleTimeoutError. + * + * This prevents the streaming loop from hanging indefinitely when: + * - Network connection drops mid-stream (TCP half-open) + * - LLM provider stalls without closing the connection + * - Proxy/gateway timeouts that don't properly terminate the stream + */ +async function* withIdleTimeout( + stream: AsyncIterable, + timeoutMs: number, + abort: AbortSignal +): AsyncGenerator { + const iterator = stream[Symbol.asyncIterator]() + + while (true) { + abort.throwIfAborted() + + let timer: ReturnType | undefined + let rejectTimeout: ((error: Error) => void) | undefined + + const timeoutPromise = new Promise((_, reject) => { + rejectTimeout = reject + timer = setTimeout(() => { + reject(new StreamIdleTimeoutError(timeoutMs)) + }, timeoutMs) + }) + + // Clean up timer when abort signal fires + const abortHandler = () => { + if (timer) clearTimeout(timer) + } + abort.addEventListener("abort", abortHandler, { once: true }) + + try { + const result = await Promise.race([ + iterator.next(), + timeoutPromise + ]) + + // Clear the timer since we got a result + if (timer) clearTimeout(timer) + abort.removeEventListener("abort", abortHandler) + + if (result.done) return + yield result.value + } catch (e) { + // Clean up on error too + if (timer) clearTimeout(timer) + abort.removeEventListener("abort", abortHandler) + throw e + } + } +} + export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -45,14 +101,22 @@ export namespace SessionProcessor { async process(streamInput: LLM.StreamInput) { log.info("process") needsCompaction = false - const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true + const config = await Config.get() + const shouldBreak = config.experimental?.continue_loop_on_deny !== true + // Default to 60 seconds between chunks, 0 disables the timeout + const streamIdleTimeout = config.experimental?.stream_idle_timeout ?? 60000 while (true) { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} const stream = await LLM.stream(streamInput) - for await (const value of stream.fullStream) { + // Wrap the stream with idle timeout to prevent hanging on stalled connections + const wrappedStream = streamIdleTimeout > 0 + ? withIdleTimeout(stream.fullStream, streamIdleTimeout, input.abort) + : stream.fullStream + + for await (const value of wrappedStream) { input.abort.throwIfAborted() switch (value.type) { case "start": From fd5e44aae2de7b770cdfbb446b08c6a514791516 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:22:21 -0800 Subject: [PATCH 11/13] test: add unit tests for StreamIdleTimeoutError --- .../test/session/stream-idle-timeout.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/opencode/test/session/stream-idle-timeout.test.ts diff --git a/packages/opencode/test/session/stream-idle-timeout.test.ts b/packages/opencode/test/session/stream-idle-timeout.test.ts new file mode 100644 index 000000000000..a0d93c57ccce --- /dev/null +++ b/packages/opencode/test/session/stream-idle-timeout.test.ts @@ -0,0 +1,20 @@ +import { describe, test, expect } from "bun:test" +import { StreamIdleTimeoutError } from "../../src/session/message-v2" + +// We can't import withIdleTimeout directly since it's not exported, +// so we test the error handling integration + +describe("StreamIdleTimeoutError", () => { + test("has correct name and message", () => { + const error = new StreamIdleTimeoutError(60000) + expect(error.name).toBe("StreamIdleTimeoutError") + expect(error.message).toBe("Stream idle timeout: no data received for 60000ms") + expect(error.timeoutMs).toBe(60000) + }) + + test("is instanceof Error", () => { + const error = new StreamIdleTimeoutError(60000) + expect(error instanceof Error).toBe(true) + expect(error instanceof StreamIdleTimeoutError).toBe(true) + }) +}) From 319a1d76b33b02785686ad5e496448ee58b7b698 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:13:13 -0800 Subject: [PATCH 12/13] feat: add install-local.sh script for local development builds --- package.json | 2 +- scripts/install-local.sh | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100755 scripts/install-local.sh diff --git a/package.json b/package.json index 4485e2d8395a..fef456c6fc78 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", "prepare": "husky", - "install:local": "bun run --cwd packages/opencode build --single --install", + "install:local": "./scripts/install-local.sh", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", "test": "echo 'do not run tests from root' && exit 1" diff --git a/scripts/install-local.sh b/scripts/install-local.sh new file mode 100755 index 000000000000..eea60aac8111 --- /dev/null +++ b/scripts/install-local.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Install opencode from local build +# Builds the binary and installs it to ~/.bun/bin/opencode + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +OPENCODE_PKG="$ROOT_DIR/packages/opencode" + +echo "๐Ÿ“ฆ Building opencode from local source..." +cd "$ROOT_DIR" + +# Build the binary for current platform +bun run --cwd packages/opencode build --single --skip-install + +# Determine platform-specific binary location +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH="x64" ;; + aarch64|arm64) ARCH="arm64" ;; +esac + +PLATFORM_PKG="opencode-${OS}-${ARCH}" +DIST_BINARY="$OPENCODE_PKG/dist/$PLATFORM_PKG/bin/opencode" + +if [ ! -f "$DIST_BINARY" ]; then + echo "โŒ Build failed: $DIST_BINARY not found" + exit 1 +fi + +# Install to bun bin directory (replacing any existing symlink or binary) +BUN_BIN="${BUN_INSTALL:-$HOME/.bun}/bin" +INSTALL_PATH="$BUN_BIN/opencode" + +echo "๐Ÿ“‹ Installing to $INSTALL_PATH..." +mkdir -p "$BUN_BIN" + +# Remove existing symlink if present +if [ -L "$INSTALL_PATH" ]; then + rm "$INSTALL_PATH" +fi + +cp "$DIST_BINARY" "$INSTALL_PATH" +chmod +x "$INSTALL_PATH" + +echo "" +echo "โœ… opencode installed successfully!" +echo "" + +# Verify installation +VERSION=$("$INSTALL_PATH" --version 2>/dev/null || echo "unknown") +echo "Version: $VERSION" +echo "Location: $INSTALL_PATH" +echo "" + +# Check if bun bin is in PATH +if ! echo "$PATH" | grep -q "$BUN_BIN"; then + echo "โš ๏ธ Note: $BUN_BIN may not be in your PATH" + echo " Add this to your shell profile:" + echo " export PATH=\"$BUN_BIN:\$PATH\"" +fi From b7f92ca0c7dce70cddd5c620a01add9c25d5c792 Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:58:38 -0800 Subject: [PATCH 13/13] fix: filter CLI sessions by current directory for worktree support When running 'opencode -c' (continue) or 'opencode session list' from a git worktree directory, the CLI was incorrectly showing sessions from all worktrees of the same repository. This happened because sessions are grouped by projectID (git root commit hash), which is the same for all worktrees. Changes: - run.ts: Pass current directory to sdk.session.list() when using -c flag - session.ts: Filter sessions to only show those matching Instance.directory This matches the existing TUI behavior which already filters by directory. --- packages/opencode/src/cli/cmd/run.ts | 566 +++++++++++++++-------- packages/opencode/src/cli/cmd/session.ts | 4 +- 2 files changed, 370 insertions(+), 200 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 4ed74ab5f31b..3f14b6c00416 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -4,26 +4,211 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" -import { Command } from "../../command" import { EOL } from "os" -import { select } from "@clack/prompts" -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" -import { Auth } from "../../auth" - -const TOOL: Record = { - todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], - edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], - glob: ["Glob", UI.Style.TEXT_INFO_BOLD], - grep: ["Grep", UI.Style.TEXT_INFO_BOLD], - list: ["List", UI.Style.TEXT_INFO_BOLD], - read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], - write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], - websearch: ["Search", UI.Style.TEXT_DIM_BOLD], +import { PermissionNext } from "../../permission/next" +import { Tool } from "../../tool/tool" +import { GlobTool } from "../../tool/glob" +import { GrepTool } from "../../tool/grep" +import { ListTool } from "../../tool/ls" +import { ReadTool } from "../../tool/read" +import { WebFetchTool } from "../../tool/webfetch" +import { EditTool } from "../../tool/edit" +import { WriteTool } from "../../tool/write" +import { CodeSearchTool } from "../../tool/codesearch" +import { WebSearchTool } from "../../tool/websearch" +import { TaskTool } from "../../tool/task" +import { SkillTool } from "../../tool/skill" +import { BashTool } from "../../tool/bash" +import { TodoWriteTool } from "../../tool/todo" +import { Locale } from "../../util/locale" + +type ToolProps = { + input: Tool.InferParameters + metadata: Tool.InferMetadata + part: ToolPart +} + +function props(part: ToolPart): ToolProps { + const state = part.state + return { + input: state.input as Tool.InferParameters, + metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata, + part, + } +} + +type Inline = { + icon: string + title: string + description?: string +} + +function inline(info: Inline) { + const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : "" + UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix) +} + +function block(info: Inline, output?: string) { + UI.empty() + inline(info) + if (!output?.trim()) return + UI.println(output) + UI.empty() +} + +function fallback(part: ToolPart) { + const state = part.state + const input = "input" in state ? state.input : undefined + const title = + ("title" in state && state.title ? state.title : undefined) || + (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown") + inline({ + icon: "โš™", + title: `${part.tool} ${title}`, + }) +} + +function glob(info: ToolProps) { + const root = info.input.path ?? "" + const title = `Glob "${info.input.pattern}"` + const suffix = root ? `in ${normalizePath(root)}` : "" + const num = info.metadata.count + const description = + num === undefined ? suffix : `${suffix}${suffix ? " ยท " : ""}${num} ${num === 1 ? "match" : "matches"}` + inline({ + icon: "โœฑ", + title, + ...(description && { description }), + }) +} + +function grep(info: ToolProps) { + const root = info.input.path ?? "" + const title = `Grep "${info.input.pattern}"` + const suffix = root ? `in ${normalizePath(root)}` : "" + const num = info.metadata.matches + const description = + num === undefined ? suffix : `${suffix}${suffix ? " ยท " : ""}${num} ${num === 1 ? "match" : "matches"}` + inline({ + icon: "โœฑ", + title, + ...(description && { description }), + }) +} + +function list(info: ToolProps) { + const dir = info.input.path ? normalizePath(info.input.path) : "" + inline({ + icon: "โ†’", + title: dir ? `List ${dir}` : "List", + }) +} + +function read(info: ToolProps) { + const file = normalizePath(info.input.filePath) + const pairs = Object.entries(info.input).filter(([key, value]) => { + if (key === "filePath") return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined + inline({ + icon: "โ†’", + title: `Read ${file}`, + ...(description && { description }), + }) +} + +function write(info: ToolProps) { + block( + { + icon: "โ†", + title: `Write ${normalizePath(info.input.filePath)}`, + }, + info.part.state.status === "completed" ? info.part.state.output : undefined, + ) +} + +function webfetch(info: ToolProps) { + inline({ + icon: "%", + title: `WebFetch ${info.input.url}`, + }) +} + +function edit(info: ToolProps) { + const title = normalizePath(info.input.filePath) + const diff = info.metadata.diff + block( + { + icon: "โ†", + title: `Edit ${title}`, + }, + diff, + ) +} + +function codesearch(info: ToolProps) { + inline({ + icon: "โ—‡", + title: `Exa Code Search "${info.input.query}"`, + }) +} + +function websearch(info: ToolProps) { + inline({ + icon: "โ—ˆ", + title: `Exa Web Search "${info.input.query}"`, + }) +} + +function task(info: ToolProps) { + const agent = Locale.titlecase(info.input.subagent_type) + const desc = info.input.description + const started = info.part.state.status === "running" + const name = desc ?? `${agent} Task` + inline({ + icon: started ? "โ€ข" : "โœ“", + title: name, + description: desc ? `${agent} Agent` : undefined, + }) +} + +function skill(info: ToolProps) { + inline({ + icon: "โ†’", + title: `Skill "${info.input.name}"`, + }) +} + +function bash(info: ToolProps) { + const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined + block( + { + icon: "$", + title: `${info.input.command}`, + }, + output, + ) +} + +function todo(info: ToolProps) { + block( + { + icon: "#", + title: "Todos", + }, + info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), + ) +} + +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." + return input } export const RunCommand = cmd({ @@ -94,33 +279,18 @@ export const RunCommand = cmd({ }) }, handler: async (args) => { - // Check for expired Kilo Code token - const { isJwtExpired } = await import("../../util/jwt") - const auth = await Auth.get("kilocode") - const defaultModel = await Provider.defaultModel().catch(() => undefined) - const isUsingKilocode = - args.model?.includes("kilocode") || - (!args.model && defaultModel?.providerID === "kilocode") - - if (isUsingKilocode && auth && auth.type === "api") { - if (isJwtExpired(auth.key)) { - UI.error("Kilo Code token has expired. Please run 'opencode auth login kilocode' to refresh it.") - process.exit(1) - } - } - let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - const fileParts: any[] = [] + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] if (args.file) { - const files = Array.isArray(args.file) ? args.file : [args.file] + const list = Array.isArray(args.file) ? args.file : [args.file] - for (const filePath of files) { + for (const filePath of list) { const resolvedPath = path.resolve(process.cwd(), filePath) const file = Bun.file(resolvedPath) - const stats = await file.stat().catch(() => { }) + const stats = await file.stat().catch(() => {}) if (!stats) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -133,7 +303,7 @@ export const RunCommand = cmd({ const stat = await file.stat() const mime = stat.isDirectory() ? "application/x-directory" : "text/plain" - fileParts.push({ + files.push({ type: "file", url: `file://${resolvedPath}`, filename: path.basename(resolvedPath), @@ -149,17 +319,75 @@ export const RunCommand = cmd({ process.exit(1) } - const execute = async (sdk: OpencodeClient, sessionID: string) => { - const printEvent = (color: string, type: string, title: string) => { - UI.println( - color + `|`, - UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, - "", - UI.Style.TEXT_NORMAL + title, - ) + const rules: PermissionNext.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + if (args.continue) { + const result = await sdk.session.list({ directory: process.cwd() }) + return result.data?.find((s) => !s.parentID)?.id } + if (args.session) return args.session + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } - const outputJsonEvent = (type: string, data: any) => { + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + } + } + + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + if (part.tool === "bash") return bash(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "list") return list(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "codesearch") return codesearch(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } + + function emit(type: string, data: Record) { if (args.format === "json") { process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) return true @@ -168,41 +396,77 @@ export const RunCommand = cmd({ } const events = await sdk.event.subscribe() - let errorMsg: string | undefined + let error: string | undefined + + async function loop() { + const toggles = new Map() - const eventProcessor = (async () => { for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} ยท ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID !== sessionID) continue if (part.type === "tool" && part.state.status === "completed") { - if (outputJsonEvent("tool_use", { part })) continue - const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] - const title = - part.state.title || - (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") - printEvent(color, tool, title) - if (part.tool === "bash" && part.state.output?.trim()) { - UI.println() - UI.println(part.state.output) - } + if (emit("tool_use", { part })) continue + tool(part) + } + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) } if (part.type === "step-start") { - if (outputJsonEvent("step_start", { part })) continue + if (emit("step_start", { part })) continue } if (part.type === "step-finish") { - if (outputJsonEvent("step_finish", { part })) continue + if (emit("step_finish", { part })) continue } if (part.type === "text" && part.time?.end) { - if (outputJsonEvent("text", { part })) continue - const isPiped = !process.stdout.isTTY - if (!isPiped) UI.println() - process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) - if (!isPiped) UI.println() + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) } } @@ -213,42 +477,40 @@ export const RunCommand = cmd({ if ("data" in props.error && props.error.data && "message" in props.error.data) { err = String(props.error.data.message) } - errorMsg = errorMsg ? errorMsg + EOL + err : err - if (outputJsonEvent("error", { error: props.error })) continue + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue UI.error(err) } - if (event.type === "session.idle" && event.properties.sessionID === sessionID) { + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { break } if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue - const result = await select({ - message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, - options: [ - { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow: " + permission.always.join(", ") }, - { value: "reject", label: "Reject" }, - ], - initialValue: "once", - }).catch(() => "reject") - const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject" - await sdk.permission.respond({ - sessionID, - permissionID: permission.id, - response, + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", }) } } - })() + } // Validate agent if specified - const resolvedAgent = await (async () => { + const agent = await (async () => { if (!args.agent) return undefined - const agent = await Agent.get(args.agent) - if (!agent) { + const entry = await Agent.get(args.agent) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -256,7 +518,7 @@ export const RunCommand = cmd({ ) return undefined } - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -267,91 +529,42 @@ export const RunCommand = cmd({ return args.agent })() + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) + } + await share(sdk, sessionID) + + loop().catch((e) => { + console.error(e) + process.exit(1) + }) + if (args.command) { await sdk.session.command({ sessionID, - agent: resolvedAgent, + agent, model: args.model, command: args.command, arguments: message, variant: args.variant, }) } else { - const modelParam = args.model ? Provider.parseModel(args.model) : undefined + const model = args.model ? Provider.parseModel(args.model) : undefined await sdk.session.prompt({ sessionID, - agent: resolvedAgent, - model: modelParam, + agent, + model, variant: args.variant, - parts: [...fileParts, { type: "text", text: message }], + parts: [...files, { type: "text", text: message }], }) } - - await eventProcessor - if (errorMsg) process.exit(1) } if (args.attach) { const sdk = createOpencodeClient({ baseUrl: args.attach }) - - const sessionID = await (async () => { - if (args.continue) { - const result = await sdk.session.list() - return result.data?.find((s) => !s.parentID)?.id - } - if (args.session) return args.session - - const title = - args.title !== undefined - ? args.title === "" - ? message.slice(0, 50) + (message.length > 50 ? "..." : "") - : args.title - : undefined - - const result = await sdk.session.create( - title - ? { - title, - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - } - : { - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }, - ) - return result.data?.id - })() - - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - - const cfgResult = await sdk.config.get() - if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { - const shareResult = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) - } - } - - return await execute(sdk, sessionID) + return await execute(sdk) } await bootstrap(process.cwd(), async () => { @@ -360,52 +573,7 @@ export const RunCommand = cmd({ return Server.App().fetch(request) }) as typeof globalThis.fetch const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - - if (args.command) { - const exists = await Command.get(args.command) - if (!exists) { - UI.error(`Command "${args.command}" not found`) - process.exit(1) - } - } - - const sessionID = await (async () => { - if (args.continue) { - const result = await sdk.session.list() - return result.data?.find((s) => !s.parentID)?.id - } - if (args.session) return args.session - - const title = - args.title !== undefined - ? args.title === "" - ? message.slice(0, 50) + (message.length > 50 ? "..." : "") - : args.title - : undefined - - const result = await sdk.session.create(title ? { title } : {}) - return result.data?.id - })() - - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - - const cfgResult = await sdk.config.get() - if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { - const shareResult = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) - } - } - - await execute(sdk, sessionID) + await execute(sdk) }) }, }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index c6a1fd4138f2..fcdee66df57c 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -7,6 +7,7 @@ import { Locale } from "../../util/locale" import { Flag } from "../../flag/flag" import { EOL } from "os" import path from "path" +import { Instance } from "../../project/instance" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -61,9 +62,10 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { + const directory = Instance.directory const sessions = [] for await (const session of Session.list()) { - if (!session.parentID) { + if (!session.parentID && session.directory === directory) { sessions.push(session) } }