Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory
2026-02-13 09:37:11 -05:00
80 changed files with 1298 additions and 353 deletions
+101 -12
View File
@@ -47,20 +47,109 @@ if (!arch) {
const base = "opencode-" + platform + "-" + arch
const binary = platform === "windows" ? "opencode.exe" : "opencode"
function supportsAvx2() {
if (arch !== "x64") return false
if (platform === "linux") {
try {
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
} catch {
return false
}
}
if (platform === "darwin") {
try {
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
encoding: "utf8",
timeout: 1500,
})
if (result.status !== 0) return false
return (result.stdout || "").trim() === "1"
} catch {
return false
}
}
if (platform === "windows") {
const cmd =
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
try {
const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], {
encoding: "utf8",
timeout: 3000,
windowsHide: true,
})
if (result.status !== 0) continue
const out = (result.stdout || "").trim().toLowerCase()
if (out === "true" || out === "1") return true
if (out === "false" || out === "0") return false
} catch {
continue
}
}
return false
}
return false
}
const names = (() => {
const avx2 = supportsAvx2()
const baseline = arch === "x64" && !avx2
if (platform === "linux") {
const musl = (() => {
try {
if (fs.existsSync("/etc/alpine-release")) return true
} catch {
// ignore
}
try {
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase()
if (text.includes("musl")) return true
} catch {
// ignore
}
return false
})()
if (musl) {
if (arch === "x64") {
if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
}
return [`${base}-musl`, base]
}
if (arch === "x64") {
if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
}
return [base, `${base}-musl`]
}
if (arch === "x64") {
if (baseline) return [`${base}-baseline`, base]
return [base, `${base}-baseline`]
}
return [base]
})()
function findBinary(startDir) {
let current = startDir
for (;;) {
const modules = path.join(current, "node_modules")
if (fs.existsSync(modules)) {
const entries = fs.readdirSync(modules)
for (const entry of entries) {
if (!entry.startsWith(base)) {
continue
}
const candidate = path.join(modules, entry, "bin", binary)
if (fs.existsSync(candidate)) {
return candidate
}
for (const name of names) {
const candidate = path.join(modules, name, "bin", binary)
if (fs.existsSync(candidate)) return candidate
}
}
const parent = path.dirname(current)
@@ -74,9 +163,9 @@ function findBinary(startDir) {
const resolved = findBinary(scriptDir)
if (!resolved) {
console.error(
'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' +
base +
'" package',
"It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " +
names.map((n) => `\"${n}\"`).join(" or ") +
" package",
)
process.exit(1)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.63",
"version": "1.1.64",
"name": "opencode",
"type": "module",
"license": "MIT",
+41 -13
View File
@@ -1,6 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { Selection } from "@tui/util/selection"
import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@@ -180,6 +181,7 @@ export function tui(input: {
exitOnCtrlC: false,
useKittyKeyboard: {},
autoFocus: false,
openConsoleOnError: false,
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
@@ -209,6 +211,35 @@ function App() {
const exit = useExit()
const promptRef = usePromptRef()
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!renderer.getSelection()) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
// - Most other key input dismisses selection and is passed through
if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) {
renderer.clearSelection()
return
}
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "escape") {
renderer.clearSelection()
evt.preventDefault()
evt.stopPropagation()
return
}
renderer.clearSelection()
})
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
@@ -216,6 +247,7 @@ function App() {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
@@ -711,19 +743,15 @@ function App() {
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
onMouseUp={async () => {
if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) {
renderer.clearSelection()
return
}
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
>
<Switch>
<Match when={route.data.type === "home"}>
+31 -16
View File
@@ -1,10 +1,11 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection"
export function Dialog(
props: ParentProps<{
@@ -16,10 +17,18 @@ export function Dialog(
const { theme } = useTheme()
const renderer = useRenderer()
let dismiss = false
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
onMouseDown={() => {
dismiss = !!renderer.getSelection()
}}
onMouseUp={() => {
if (dismiss) {
dismiss = false
return
}
props.onClose?.()
}}
width={dimensions().width}
@@ -32,8 +41,8 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
onMouseUp={(e) => {
dismiss = false
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
@@ -56,8 +65,13 @@ function init() {
size: "medium" as "medium" | "large",
})
const renderer = useRenderer()
useKeyboard((evt) => {
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
@@ -67,7 +81,6 @@ function init() {
}
})
const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
@@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) {
{props.children}
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
onMouseDown={(evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return
if (!Selection.copy(renderer, toast)) return
evt.preventDefault()
evt.stopPropagation()
}}
onMouseUp={
!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined
}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
@@ -0,0 +1,25 @@
import { Clipboard } from "./clipboard"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
error: (err: unknown) => void
}
type Renderer = {
getSelection: () => { getSelectedText: () => string } | null
clearSelection: () => void
}
export namespace Selection {
export function copy(renderer: Renderer, toast: Toast): boolean {
const text = renderer.getSelection()?.getSelectedText()
if (!text) return false
Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
return true
}
}
+7 -1
View File
@@ -175,8 +175,14 @@ export namespace Config {
}
// Inline config content overrides all non-managed config sources.
// Route through load() to enable {env:} and {file:} token substitution.
// Use a path within Instance.directory so relative {file:} paths resolve correctly.
// The filename "OPENCODE_CONFIG_CONTENT" appears in error messages for clarity.
if (Flag.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
result = mergeConfigConcatArrays(
result,
await load(Flag.OPENCODE_CONFIG_CONTENT, path.join(Instance.directory, "OPENCODE_CONFIG_CONTENT")),
)
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
+16 -2
View File
@@ -8,7 +8,7 @@ export namespace Flag {
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export declare const OPENCODE_CONFIG_CONTENT: string | undefined
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
@@ -37,7 +37,10 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER")
export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT =
copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
export const OPENCODE_ENABLE_EXA =
truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")
export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS")
@@ -91,3 +94,14 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CONFIG_CONTENT
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", {
get() {
return process.env["OPENCODE_CONFIG_CONTENT"]
},
enumerable: true,
configurable: false,
})
+20 -26
View File
@@ -2,7 +2,6 @@ import z from "zod"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { $ } from "bun"
import { Storage } from "../storage/storage"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
@@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -55,15 +55,15 @@ export namespace Project {
const { id, sandbox, worktree, vcs } = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const git = await matches.next().then((x) => x.value)
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (git) {
let sandbox = path.dirname(git)
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
// cached id calculation
let id = await Bun.file(path.join(git, "opencode"))
let id = await Bun.file(path.join(dotgit, "opencode"))
.text()
.then((x) => x.trim())
.catch(() => undefined)
@@ -79,13 +79,11 @@ export namespace Project {
// generate id from root commit
if (!id) {
const roots = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) =>
x
const roots = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
@@ -104,7 +102,7 @@ export namespace Project {
id = roots[0]
if (id) {
void Bun.file(path.join(git, "opencode"))
void Bun.file(path.join(dotgit, "opencode"))
.write(id)
.catch(() => undefined)
}
@@ -119,12 +117,10 @@ export namespace Project {
}
}
const top = await $`git rev-parse --show-toplevel`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) => path.resolve(sandbox, x.trim()))
const top = await git(["rev-parse", "--show-toplevel"], {
cwd: sandbox,
})
.then(async (result) => path.resolve(sandbox, (await result.text()).trim()))
.catch(() => undefined)
if (!top) {
@@ -138,13 +134,11 @@ export namespace Project {
sandbox = top
const worktree = await $`git rev-parse --git-common-dir`
.quiet()
.nothrow()
.cwd(sandbox)
.text()
.then((x) => {
const dirname = path.dirname(x.trim())
const worktree = await git(["rev-parse", "--git-common-dir"], {
cwd: sandbox,
})
.then(async (result) => {
const dirname = path.dirname((await result.text()).trim())
if (dirname === ".") return sandbox
return dirname
})
+15 -3
View File
@@ -14,6 +14,8 @@ import { Env } from "../env"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { Global } from "../global"
import path from "path"
// Direct imports for bundled providers
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
@@ -1229,9 +1231,19 @@ export namespace Provider {
const cfg = await Config.get()
if (cfg.model) return parseModel(cfg.model)
const provider = await list()
.then((val) => Object.values(val))
.then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)))
const providers = await list()
const recent = (await Bun.file(path.join(Global.Path.state, "model.json"))
.json()
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
.catch(() => [])) as { providerID: string; modelID: string }[]
for (const entry of recent) {
const provider = providers[entry.providerID]
if (!provider) continue
if (!provider.models[entry.modelID]) continue
return { providerID: entry.providerID, modelID: entry.modelID }
}
const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
if (!provider) throw new Error("no providers found")
const [model] = sort(Object.values(provider.models))
if (!model) throw new Error("no models found")
+50 -16
View File
@@ -4,7 +4,6 @@ import { type IPty } from "bun-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
@@ -17,6 +16,22 @@ export namespace Pty {
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sockets = new WeakMap<object, number>()
let socketCounter = 0
const tagSocket = (ws: Socket) => {
if (!ws || typeof ws !== "object") return
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
sockets.set(ws, next)
return next
}
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
@@ -81,7 +96,7 @@ export namespace Pty {
buffer: string
bufferCursor: number
cursor: number
subscribers: Set<WSContext>
subscribers: Map<Socket, number>
}
const state = Instance.state(
@@ -91,8 +106,12 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
}
sessions.clear()
@@ -154,18 +173,26 @@ export namespace Pty {
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Set(),
subscribers: new Map(),
}
state().set(id, session)
ptyProcess.onData((data) => {
session.cursor += data.length
for (const ws of session.subscribers) {
for (const [ws, id] of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
ws.send(data)
if (typeof ws === "object" && sockets.get(ws) !== id) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(data)
} catch {
session.subscribers.delete(ws)
}
}
session.buffer += data
@@ -177,14 +204,15 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
for (const ws of session.subscribers) {
ws.close()
}
state().delete(id)
})
Bus.publish(Event.Created, { info })
@@ -211,9 +239,14 @@ export namespace Pty {
try {
session.process.kill()
} catch {}
for (const ws of session.subscribers) {
ws.close()
for (const ws of session.subscribers.keys()) {
try {
ws.close()
} catch {
// ignore
}
}
session.subscribers.clear()
state().delete(id)
Bus.publish(Event.Deleted, { id })
}
@@ -232,7 +265,7 @@ export namespace Pty {
}
}
export function connect(id: string, ws: WSContext, cursor?: number) {
export function connect(id: string, ws: Socket, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
@@ -272,7 +305,8 @@ export namespace Pty {
return
}
session.subscribers.add(ws)
const socketId = tagSocket(ws)
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
+20 -1
View File
@@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() =>
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
type Socket = {
readyState: number
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const isSocket = (value: unknown): value is Socket => {
if (!value || typeof value !== "object") return false
if (!("readyState" in value)) return false
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
return typeof (value as { readyState?: unknown }).readyState === "number"
}
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws, cursor)
const socket = isSocket(ws.raw) ? ws.raw : ws
handler = Pty.connect(id, socket, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
@@ -170,6 +186,9 @@ export const PtyRoutes = lazy(() =>
onClose() {
handler?.onClose()
},
onError() {
handler?.onClose()
},
}
}),
),
+3 -2
View File
@@ -2,6 +2,7 @@ import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
import z from "zod"
import { Config } from "../config/config"
@@ -23,7 +24,7 @@ export namespace Snapshot {
}
export async function cleanup() {
if (Instance.project.vcs !== "git") return
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
@@ -48,7 +49,7 @@ export namespace Snapshot {
}
export async function track() {
if (Instance.project.vcs !== "git") return
if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return
const cfg = await Config.get()
if (cfg.snapshot === false) return
const git = gitdir()
+64
View File
@@ -0,0 +1,64 @@
import { $ } from "bun"
import { Flag } from "../flag/flag"
export interface GitResult {
exitCode: number
text(): string | Promise<string>
stdout: Buffer | ReadableStream<Uint8Array>
stderr: Buffer | ReadableStream<Uint8Array>
}
/**
* Run a git command.
*
* Uses Bun's lightweight `$` shell by default. When the process is running
* as an ACP client, child processes inherit the parent's stdin pipe which
* carries protocol data on Windows this causes git to deadlock. In that
* case we fall back to `Bun.spawn` with `stdin: "ignore"`.
*/
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") {
try {
const proc = Bun.spawn(["git", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
cwd: opts.cwd,
env: opts.env ? { ...process.env, ...opts.env } : process.env,
})
// Read output concurrently with exit to avoid pipe buffer deadlock
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).arrayBuffer(),
])
const stdoutBuf = Buffer.from(stdout)
const stderrBuf = Buffer.from(stderr)
return {
exitCode,
text: () => stdoutBuf.toString(),
stdout: stdoutBuf,
stderr: stderrBuf,
}
} catch (error) {
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))
return {
exitCode: 1,
text: () => "",
stdout: Buffer.alloc(0),
stderr,
}
}
}
const env = opts.env ? { ...process.env, ...opts.env } : undefined
let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd)
if (env) cmd = cmd.env(env)
const result = await cmd
return {
exitCode: result.exitCode,
text: () => result.text(),
stdout: result.stdout,
stderr: result.stderr,
}
}
+55 -26
View File
@@ -420,49 +420,78 @@ export namespace Worktree {
}
const directory = await canonical(input.directory)
const locate = async (stdout: Uint8Array | undefined) => {
const lines = outputText(stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
return (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
}
const clean = (target: string) =>
fs
.rm(target, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
})
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
const lines = outputText(list.stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
const entry = await (async () => {
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
const entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await fs.rm(directory, { recursive: true, force: true })
await clean(directory)
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
if (removed.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
})
}
const stale = await locate(next.stdout)
if (stale?.path) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
}
}
await clean(entry.path)
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
@@ -1800,3 +1800,68 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
}
})
})
// OPENCODE_CONFIG_CONTENT should support {env:} and {file:} token substitution
// just like file-based config sources do.
describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
const originalTestVar = process.env["TEST_CONFIG_VAR"]
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_CONFIG_VAR}",
})
try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_api_key_12345")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
} else {
delete process.env["OPENCODE_CONFIG_CONTENT"]
}
if (originalTestVar !== undefined) {
process.env["TEST_CONFIG_VAR"] = originalTestVar
} else {
delete process.env["TEST_CONFIG_VAR"]
}
}
})
test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => {
const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"]
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{file:./api_key.txt}",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_key_from_file")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv
} else {
delete process.env["OPENCODE_CONFIG_CONTENT"]
}
}
})
})
+25 -34
View File
@@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const bunModule = await import("bun")
const gitModule = await import("../../src/util/git")
const originalGit = gitModule.git
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
let mode: Mode = "none"
function render(parts: TemplateStringsArray, vals: unknown[]) {
return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "")
}
function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) {
const result = {
exitCode: output.exitCode,
stdout: Buffer.from(output.stdout),
stderr: Buffer.from(output.stderr),
text: async () => output.stdout,
}
const shell = {
quiet: () => shell,
nothrow: () => shell,
cwd: () => shell,
env: () => shell,
text: async () => output.stdout,
then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) =>
Promise.resolve(result).then(onfulfilled, onrejected),
catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected),
finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally),
}
return shell
}
mock.module("bun", () => ({
...bunModule,
$: (parts: TemplateStringsArray, ...vals: unknown[]) => {
const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim()
mock.module("../../src/util/git", () => ({
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
const cmd = ["git", ...args].join(" ")
if (
mode === "rev-list-fail" &&
cmd.includes("git rev-list") &&
cmd.includes("--max-parents=0") &&
cmd.includes("--all")
) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" })
return Promise.resolve({
exitCode: 128,
text: () => Promise.resolve(""),
stdout: Buffer.from(""),
stderr: Buffer.from("fatal"),
})
}
return (bunModule.$ as any)(parts, ...vals)
return originalGit(args, opts)
},
}))
@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { tmpdir } from "../fixture/fixture"
describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-regression-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
const real = (await $`which git`.quiet().text()).trim()
expect(real).toBeTruthy()
const bin = path.join(root, "bin")
const shim = path.join(bin, "git")
await fs.mkdir(bin, { recursive: true })
await Bun.write(
shim,
[
"#!/bin/bash",
`REAL_GIT=${JSON.stringify(real)}`,
'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then',
' "$REAL_GIT" "$@" >/dev/null 2>&1',
' echo "fatal: failed to remove worktree: Directory not empty" >&2',
" exit 1",
"fi",
'exec "$REAL_GIT" "$@"',
].join("\n"),
)
await fs.chmod(shim, 0o755)
const prev = process.env.PATH ?? ""
process.env.PATH = `${bin}${path.delimiter}${prev}`
const ok = await (async () => {
try {
return await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
} finally {
process.env.PATH = prev
}
})()
expect(ok).toBe(true)
expect(await Bun.file(dir).exists()).toBe(false)
const list = await $`git worktree list --porcelain`.cwd(root).quiet().text()
expect(list).not.toContain(`worktree ${dir}`)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
})
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
directory: dir.path,
fn: async () => {
const a = await Pty.create({ command: "cat", title: "a" })
const b = await Pty.create({ command: "cat", title: "b" })
try {
const outA: string[] = []
const outB: string[] = []
const ws = {
readyState: 1,
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
},
close: () => {
// no-op (simulate abrupt drop)
},
}
// Connect "a" first with ws.
Pty.connect(a.id, ws as any)
// Now "reuse" the same ws object for another connection.
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
}
Pty.connect(b.id, ws as any)
// Clear connect metadata writes.
outA.length = 0
outB.length = 0
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
await Pty.remove(a.id)
await Pty.remove(b.id)
}
},
})
})
})