diff --git a/package.json b/package.json index e1471d356ac..fef456c6fc7 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": "./scripts/install-local.sh", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", "test": "echo 'do not run tests from root' && exit 1" @@ -101,4 +102,4 @@ "patchedDependencies": { "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" } -} +} \ No newline at end of file diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index f0b3fa828a7..3ee5e2aaadb 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -35,58 +35,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) => { @@ -179,7 +179,17 @@ for (const item of targets) { binaries[name] = Script.version } -if (Script.release) { +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}`) +} else if (Script.release) { for (const key of Object.keys(binaries)) { if (key.includes("linux")) { await $`tar -czf ../../${key}.tar.gz *`.cwd(`dist/${key}/bin`) diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index ce948b92ac8..742f21c9efe 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -20,6 +20,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/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 34e2269d0c1..6115eeb9251 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -11,6 +11,7 @@ import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" +import open from "open" type PluginAuth = NonNullable @@ -164,7 +165,7 @@ export const AuthCommand = cmd({ describe: "manage credentials", builder: (yargs) => yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), - async handler() {}, + async handler() { }, }) export const AuthListCommand = cmd({ @@ -276,6 +277,7 @@ export const AuthLoginCommand = cmd({ google: 4, openrouter: 5, vercel: 6, + kilocode: 7, } let provider = await prompts.autocomplete({ message: "Select provider", @@ -307,6 +309,68 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() + // Handle Kilo Code device authorization flow + 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.findLast((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { const handled = await handlePluginAuth({ auth: plugin.auth }, provider) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 54248f96f3d..3f14b6c0041 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -4,25 +4,211 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" -import { Command } from "../../command" import { EOL } from "os" -import { select } from "@clack/prompts" -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" +import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" +import { PermissionNext } from "../../permission/next" +import { Tool } from "../../tool/tool" +import { GlobTool } from "../../tool/glob" +import { GrepTool } from "../../tool/grep" +import { ListTool } from "../../tool/ls" +import { ReadTool } from "../../tool/read" +import { WebFetchTool } from "../../tool/webfetch" +import { EditTool } from "../../tool/edit" +import { WriteTool } from "../../tool/write" +import { CodeSearchTool } from "../../tool/codesearch" +import { WebSearchTool } from "../../tool/websearch" +import { TaskTool } from "../../tool/task" +import { SkillTool } from "../../tool/skill" +import { BashTool } from "../../tool/bash" +import { TodoWriteTool } from "../../tool/todo" +import { Locale } from "../../util/locale" + +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}"`, + }) +} -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], +function bash(info: ToolProps) { + const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined + block( + { + icon: "$", + title: `${info.input.command}`, + }, + output, + ) +} + +function todo(info: ToolProps) { + block( + { + icon: "#", + title: "Todos", + }, + info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"), + ) +} + +function normalizePath(input?: string) { + if (!input) return "" + if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "." + return input } export const RunCommand = cmd({ @@ -97,11 +283,11 @@ export const RunCommand = cmd({ .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") - const fileParts: any[] = [] + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] if (args.file) { - const files = Array.isArray(args.file) ? args.file : [args.file] + const list = Array.isArray(args.file) ? args.file : [args.file] - for (const filePath of files) { + for (const filePath of list) { const resolvedPath = path.resolve(process.cwd(), filePath) const file = Bun.file(resolvedPath) const stats = await file.stat().catch(() => {}) @@ -117,7 +303,7 @@ export const RunCommand = cmd({ const stat = await file.stat() const mime = stat.isDirectory() ? "application/x-directory" : "text/plain" - fileParts.push({ + files.push({ type: "file", url: `file://${resolvedPath}`, filename: path.basename(resolvedPath), @@ -133,17 +319,75 @@ export const RunCommand = cmd({ process.exit(1) } - const execute = async (sdk: OpencodeClient, sessionID: string) => { - const printEvent = (color: string, type: string, title: string) => { - UI.println( - color + `|`, - UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, - "", - UI.Style.TEXT_NORMAL + title, - ) + const rules: PermissionNext.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + if (args.continue) { + const result = await sdk.session.list({ 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 @@ -152,41 +396,77 @@ export const RunCommand = cmd({ } const events = await sdk.event.subscribe() - let errorMsg: string | undefined + let error: string | undefined + + async function loop() { + const toggles = new Map() - const eventProcessor = (async () => { for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + if (event.type === "message.part.updated") { const part = event.properties.part if (part.sessionID !== sessionID) continue if (part.type === "tool" && part.state.status === "completed") { - if (outputJsonEvent("tool_use", { part })) continue - const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] - const title = - part.state.title || - (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") - printEvent(color, tool, title) - if (part.tool === "bash" && part.state.output?.trim()) { - UI.println() - UI.println(part.state.output) - } + if (emit("tool_use", { part })) continue + tool(part) + } + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) } if (part.type === "step-start") { - if (outputJsonEvent("step_start", { part })) continue + if (emit("step_start", { part })) continue } if (part.type === "step-finish") { - if (outputJsonEvent("step_finish", { part })) continue + if (emit("step_finish", { part })) continue } if (part.type === "text" && part.time?.end) { - if (outputJsonEvent("text", { part })) continue - const isPiped = !process.stdout.isTTY - if (!isPiped) UI.println() - process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) - if (!isPiped) UI.println() + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) } } @@ -197,42 +477,40 @@ export const RunCommand = cmd({ if ("data" in props.error && props.error.data && "message" in props.error.data) { err = String(props.error.data.message) } - errorMsg = errorMsg ? errorMsg + EOL + err : err - if (outputJsonEvent("error", { error: props.error })) continue + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue UI.error(err) } - if (event.type === "session.idle" && event.properties.sessionID === sessionID) { + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { break } if (event.type === "permission.asked") { const permission = event.properties if (permission.sessionID !== sessionID) continue - const result = await select({ - message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, - options: [ - { value: "once", label: "Allow once" }, - { value: "always", label: "Always allow: " + permission.always.join(", ") }, - { value: "reject", label: "Reject" }, - ], - initialValue: "once", - }).catch(() => "reject") - const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject" - await sdk.permission.respond({ - sessionID, - permissionID: permission.id, - response, + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", }) } } - })() + } // Validate agent if specified - const resolvedAgent = await (async () => { + const agent = await (async () => { if (!args.agent) return undefined - const agent = await Agent.get(args.agent) - if (!agent) { + const entry = await Agent.get(args.agent) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -240,7 +518,7 @@ export const RunCommand = cmd({ ) return undefined } - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -251,91 +529,42 @@ export const RunCommand = cmd({ return args.agent })() + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) + } + await share(sdk, sessionID) + + loop().catch((e) => { + console.error(e) + process.exit(1) + }) + if (args.command) { await sdk.session.command({ sessionID, - agent: resolvedAgent, + agent, model: args.model, command: args.command, arguments: message, variant: args.variant, }) } else { - const modelParam = args.model ? Provider.parseModel(args.model) : undefined + const model = args.model ? Provider.parseModel(args.model) : undefined await sdk.session.prompt({ sessionID, - agent: resolvedAgent, - model: modelParam, + agent, + model, variant: args.variant, - parts: [...fileParts, { type: "text", text: message }], + parts: [...files, { type: "text", text: message }], }) } - - await eventProcessor - if (errorMsg) process.exit(1) } if (args.attach) { const sdk = createOpencodeClient({ baseUrl: args.attach }) - - const sessionID = await (async () => { - if (args.continue) { - const result = await sdk.session.list() - return result.data?.find((s) => !s.parentID)?.id - } - if (args.session) return args.session - - const title = - args.title !== undefined - ? args.title === "" - ? message.slice(0, 50) + (message.length > 50 ? "..." : "") - : args.title - : undefined - - const result = await sdk.session.create( - title - ? { - title, - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - } - : { - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }, - ) - return result.data?.id - })() - - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - - const cfgResult = await sdk.config.get() - if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { - const shareResult = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) - } - } - - return await execute(sdk, sessionID) + return await execute(sdk) } await bootstrap(process.cwd(), async () => { @@ -344,52 +573,7 @@ export const RunCommand = cmd({ return Server.App().fetch(request) }) as typeof globalThis.fetch const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - - if (args.command) { - const exists = await Command.get(args.command) - if (!exists) { - UI.error(`Command "${args.command}" not found`) - process.exit(1) - } - } - - const sessionID = await (async () => { - if (args.continue) { - const result = await sdk.session.list() - return result.data?.find((s) => !s.parentID)?.id - } - if (args.session) return args.session - - const title = - args.title !== undefined - ? args.title === "" - ? message.slice(0, 50) + (message.length > 50 ? "..." : "") - : args.title - : undefined - - const result = await sdk.session.create(title ? { title } : {}) - return result.data?.id - })() - - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - - const cfgResult = await sdk.config.get() - if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) { - const shareResult = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) - } - } - - await execute(sdk, sessionID) + await execute(sdk) }) }, }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index c6a1fd4138f..fcdee66df57 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) } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7969e307957..c2a22e469eb 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/provider/models.ts b/packages/opencode/src/provider/models.ts index 07881cbfe22..f22388306c1 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -5,14 +5,12 @@ import z from "zod" import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" - -// Try to import bundled snapshot (generated at build time) -// Falls back to undefined in dev mode when snapshot doesn't exist -/* @ts-ignore */ +import { Auth } from "../auth" export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) const filepath = path.join(Global.Path.cache, "models.json") + const kilocodeFilepath = path.join(Global.Path.cache, "kilocode-models.json") export const Model = z.object({ id: z.string(), @@ -100,7 +98,123 @@ export namespace ModelsDev { export async function get() { const result = await Data() - return result as Record + const database = result as Record + + // Add Kilocode provider if not present + if (!database["kilocode"]) { + database["kilocode"] = { + id: "kilocode", + name: "Kilo Code", + env: ["KILOCODE_API_KEY"], + npm: "@ai-sdk/openai-compatible", + models: { + "minimax/max-m2": { + id: "minimax/max-m2", + name: "Minimax M2", + release_date: "2024-12-01", + attachment: true, + reasoning: false, + temperature: true, + tool_call: true, + cost: { + input: 0, + output: 0, + }, + limit: { + context: 128000, + output: 8192, + }, + options: {}, + }, + }, + } + } + + // Try to load kilocode models from local cache first + const kilocodeCache = await Bun.file(kilocodeFilepath) + .json() + .catch(() => null) + if (kilocodeCache) { + Object.assign(database["kilocode"].models, kilocodeCache) + } + + // Fetch dynamic kilocode models if authenticated + 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 refreshKilocodeModels = async () => { + try { + 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", + "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)) + } + } 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 }) + } + } + + if (!kilocodeCache) { + // Block if no cache exists yet + await refreshKilocodeModels() + } else { + // Refresh in background if we have a cache + refreshKilocodeModels() + } + } + + return database } export async function refresh() { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e01c583ff34..42534fa001e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -119,6 +119,37 @@ export namespace Provider { options: hasKey ? {} : { apiKey: "public" }, } }, + 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 { + 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" + })() + + return { + autoload: !!key, + options: { + 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", + "User-Agent": "Kilo-Code/4.138.0", + }, + }, + } + }, openai: async () => { return { autoload: false, diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 6358c6c5e9b..d35b3972bf9 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 27071056180..2f099979dce 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": diff --git a/packages/opencode/src/util/jwt.ts b/packages/opencode/src/util/jwt.ts new file mode 100644 index 00000000000..c39e0cfdc30 --- /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 +} 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 00000000000..a0d93c57ccc --- /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) + }) +}) diff --git a/scripts/install-local.sh b/scripts/install-local.sh new file mode 100755 index 00000000000..eea60aac811 --- /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 diff --git a/sst.config.ts b/sst.config.ts index b8e56473bc5..f78623458cb 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,12 @@ export default $config({ }, planetscale: "0.4.1", }, - } + }; }, async run() { await import("./infra/app.js") await import("./infra/console.js") await import("./infra/enterprise.js") + await import("./infra/desktop.js") }, -}) +});