Compare commits

..

6 Commits

Author SHA1 Message Date
Dax Raad
add16af117 fix(opencode): fail on brew repo lookup errors 2026-03-06 01:33:59 -05:00
Dax Raad
054f848307 fix(opencode): honor nothrow on spawn errors 2026-03-06 01:30:12 -05:00
Dax Raad
3b2e3afebd fix(opencode): address migration review feedback 2026-03-06 01:25:04 -05:00
Dax Raad
f1c7d4cefb Merge remote-tracking branch 'origin/dev' into chore/remove-bun-shell-opencode
# Conflicts:
#	packages/opencode/src/file/index.ts
#	packages/opencode/src/worktree/index.ts
2026-03-06 01:15:26 -05:00
Dax Raad
1454dd1dc1 refactor(opencode): remove remaining Bun shell usage 2026-03-06 01:09:00 -05:00
Dax Raad
5d68b1b148 refactor(opencode): replace Bun shell in core flows 2026-03-06 00:54:11 -05:00
21 changed files with 662 additions and 655 deletions

View File

@@ -27,8 +27,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = {
login: string
@@ -255,7 +256,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -493,6 +494,26 @@ export const GithubRunCommand = cmd({
? "pr_review"
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
await gitRun(args)
}
try {
if (useGithubToken) {
@@ -553,7 +574,7 @@ export const GithubRunCommand = cmd({
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
@@ -587,7 +608,7 @@ export const GithubRunCommand = cmd({
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -605,7 +626,7 @@ export const GithubRunCommand = cmd({
// Fork PR
else {
const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -624,7 +645,7 @@ export const GithubRunCommand = cmd({
// Issue
else {
const branch = await checkoutNewBranch("issue")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -658,7 +679,7 @@ export const GithubRunCommand = cmd({
exitCode = 1
console.error(e instanceof Error ? e.message : String(e))
let msg = e
if (e instanceof $.ShellError) {
if (e instanceof Process.RunFailedError) {
msg = e.stderr.toString()
} else if (e instanceof Error) {
msg = e.message
@@ -1049,29 +1070,29 @@ export const GithubRunCommand = cmd({
const config = "http.https://github.com/.extraheader"
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow()
const ret = await gitStatus(["config", "--local", "--get", config])
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}`
await gitRun(["config", "--local", "--unset-all", config])
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
}
async function restoreGitConfig() {
if (gitConfig === undefined) return
const config = "http.https://github.com/.extraheader"
await $`git config --local ${config} "${gitConfig}"`
await gitRun(["config", "--local", config, gitConfig])
}
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
await gitRun(["checkout", "-b", branch])
return branch
}
@@ -1081,8 +1102,8 @@ export const GithubRunCommand = cmd({
const branch = pr.headRefName
const depth = Math.max(pr.commits.totalCount, 20)
await $`git fetch origin --depth=${depth} ${branch}`
await $`git checkout ${branch}`
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
await gitRun(["checkout", branch])
}
async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1092,9 +1113,9 @@ export const GithubRunCommand = cmd({
const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20)
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
return localBranch
}
@@ -1115,28 +1136,23 @@ export const GithubRunCommand = cmd({
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...")
if (commit) {
await $`git add .`
await gitRun(["add", "."])
if (isSchedule) {
// No co-author for scheduled events - the schedule is operating as the repo
await $`git commit -m "${summary}"`
await commitChanges(summary)
} else {
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await commitChanges(summary, actor)
}
}
await $`git push -u origin ${branch}`
await gitRun(["push", "-u", "origin", branch])
}
async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...")
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push`
await gitRun(["push"])
}
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1145,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const remoteBranch = pr.headRefName
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push fork HEAD:${remoteBranch}`
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
}
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await $`git status --porcelain`
const ret = await gitStatus(["status", "--porcelain"])
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
return {
dirty: head !== originalHead,
uncommittedChanges: false,
@@ -1180,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
await gitStatus(["fetch", "origin", base, "--depth=1"])
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}

View File

@@ -1,7 +1,8 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Instance } from "@/project/instance"
import { $ } from "bun"
import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({
command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult =
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined
if (prInfoResult.exitCode === 0) {
const prInfoText = prInfoResult.text()
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await $`git remote`.nothrow().text()).trim()
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}
// Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
if (importResult.exitCode === 0) {
const importOutput = importResult.text().trim()
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {

View File

@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -337,7 +338,7 @@ export const RunCommand = cmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")

View File

@@ -3,6 +3,7 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
@@ -53,7 +54,7 @@ async function target() {
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
if (!value) return piped
if (!piped) return value
return piped + "\n" + value

View File

@@ -1,9 +1,9 @@
import { $ } from "bun"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
@@ -34,23 +34,38 @@ export namespace Clipboard {
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
@@ -58,13 +73,15 @@ export namespace Clipboard {
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
@@ -81,7 +98,7 @@ export namespace Clipboard {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}

View File

@@ -3,11 +3,11 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { Global } from "../../global"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
interface UninstallArgs {
keepConfig: boolean
@@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result =
method === "choco"
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
: await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
if (
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
nothrow: true,
})
if (result.code !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
if (method === "choco" && text.includes("not running from an elevated command shell")) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)

View File

@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
@@ -11,6 +10,7 @@ import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
export namespace File {
const log = Log.create({ service: "file" })
@@ -418,11 +418,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git -c core.fsmonitor=false -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const changedFiles: Info[] = []
@@ -439,12 +439,14 @@ export namespace File {
}
}
const untrackedOutput =
await $`git -c core.fsmonitor=false -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const untrackedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -465,12 +467,14 @@ export namespace File {
}
// Get deleted files
const deletedOutput =
await $`git -c core.fsmonitor=false -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const deletedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -541,16 +545,14 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = await $`git -c core.fsmonitor=false diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
if (!diff.trim()) {
diff = await $`git -c core.fsmonitor=false diff --staged ${file}`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
).text()
}
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,

View File

@@ -5,7 +5,7 @@ import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
@@ -338,7 +338,7 @@ export namespace Ripgrep {
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
@@ -354,14 +354,16 @@ export namespace Ripgrep {
args.push("--")
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines

View File

@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -88,13 +88,10 @@ export namespace FileWatcher {
}
if (Instance.project.vcs === "git") {
const vcsDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => path.resolve(Instance.worktree, x.trim()))
.catch(() => undefined)
const result = await git(["rev-parse", "--git-dir"], {
cwd: Instance.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")

View File

@@ -1,11 +1,12 @@
import { BusEvent } from "@/bus/bus-event"
import path from "path"
import { $ } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global {
const OPENCODE_VERSION: string
@@ -15,6 +16,38 @@ declare global {
export namespace Installation {
const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
@@ -65,31 +98,31 @@ export namespace Installation {
const checks = [
{
name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
command: () => text(["npm", "list", "-g", "--depth=0"]),
},
{
name: "yarn" as const,
command: () => $`yarn global list`.throws(false).quiet().text(),
command: () => text(["yarn", "global", "list"]),
},
{
name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).quiet().text(),
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(),
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
@@ -121,61 +154,70 @@ export namespace Installation {
)
async function getBrewFormula() {
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
}
export async function upgrade(method: Method, target: string) {
let cmd
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) {
case "curl":
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
result = await upgradeCurl(target)
break
case "npm":
cmd = $`npm install -g opencode-ai@${target}`
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "pnpm":
cmd = $`pnpm install -g opencode-ai@${target}`
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "bun":
cmd = $`bun install -g opencode-ai@${target}`
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "brew": {
const formula = await getBrewFormula()
if (formula.includes("/")) {
cmd =
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
{
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
},
)
break
}
cmd = $`brew upgrade ${formula}`.env({
const env = {
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
})
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}`
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break
case "scoop":
cmd = $`scoop install opencode@${target}`
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break
default:
throw new Error(`Unknown method: ${method}`)
}
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({
stderr: stderr,
})
@@ -186,7 +228,7 @@ export namespace Installation {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
await Process.text([process.execPath, "--version"], { nothrow: true })
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -199,7 +241,7 @@ export namespace Installation {
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
@@ -215,7 +257,7 @@ export namespace Installation {
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})

View File

@@ -4,7 +4,6 @@ import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -21,6 +20,8 @@ export namespace LSPServer {
.stat(p)
.then(() => true)
.catch(() => false)
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
export interface Handle {
process: ChildProcessWithoutNullStreams
@@ -205,8 +206,8 @@ export namespace LSPServer {
await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await $`${npmCmd} install`.cwd(finalPath).quiet()
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
log.info("installed VS Code ESLint server", { serverPath })
}
@@ -602,10 +603,11 @@ export namespace LSPServer {
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
const cwd = path.join(Global.Path.bin, "elixir-ls-master")
const env = { MIX_ENV: "prod", ...process.env }
await Process.run(["mix", "deps.get"], { cwd, env })
await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
@@ -706,7 +708,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -719,7 +721,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info(`installed zls`, { bin })
@@ -831,11 +833,11 @@ export namespace LSPServer {
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!which("xcrun")) return
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
if (lspLoc.exitCode !== 0) return
if (lspLoc.code !== 0) return
const bin = lspLoc.text().trim()
const bin = lspLoc.text.trim()
return {
process: spawn(bin, {
@@ -1010,7 +1012,7 @@ export namespace LSPServer {
if (!ok) return
}
if (tar) {
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
}
await fs.rm(archive, { force: true })
@@ -1021,7 +1023,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1138,13 +1140,10 @@ export namespace LSPServer {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
}
const javaMajorVersion = await $`java -version`
.quiet()
.nothrow()
.then(({ stderr }) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
return !m ? undefined : parseInt(m[1])
})
const javaMajorVersion = await run(["java", "-version"]).then((result) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
return !m ? undefined : parseInt(m[1])
})
if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.")
return
@@ -1161,27 +1160,27 @@ export namespace LSPServer {
const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
if (curlResult.exitCode !== 0) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
return
}
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
log.info("Extracting JDTLS archive")
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
if (tarResult.exitCode !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
if (tarResult.code !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
return
}
await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
}
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)
.quiet()
.nothrow()
.then(({ stdout }) => stdout.toString().trim())
const jarFileName =
(await fs.readdir(launcherDir).catch(() => []))
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
?.trim() ?? ""
const launcherJar = path.join(launcherDir, jarFileName)
if (!(await pathExists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
@@ -1294,7 +1293,15 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true })
const archivePath = path.join(distPath, "kotlin-ls.zip")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download Kotlin Language Server", {
status: download.status,
statusText: download.statusText,
})
return
}
await Filesystem.writeStream(archivePath, download.body)
const ok = await Archive.extractZip(archivePath, distPath)
.then(() => true)
.catch((error) => {
@@ -1304,7 +1311,7 @@ export namespace LSPServer {
if (!ok) return
await fs.rm(archivePath, { force: true })
if (process.platform !== "win32") {
await $`chmod +x ${launcherScript}`.quiet().nothrow()
await fs.chmod(launcherScript, 0o755).catch(() => {})
}
log.info("Installed Kotlin Language Server", { path: launcherScript })
}
@@ -1468,10 +1475,9 @@ export namespace LSPServer {
})
if (!ok) return
} else {
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
.quiet()
.then(() => true)
.catch((error) => {
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
.then((result) => result.code === 0)
.catch((error: unknown) => {
log.error("Failed to extract lua-language-server archive", { error })
return false
})
@@ -1489,11 +1495,15 @@ export namespace LSPServer {
}
if (platform !== "win32") {
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
const ok = await fs
.chmod(bin, 0o755)
.then(() => true)
.catch((error: unknown) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
return false
})
})
if (!ok) return
}
@@ -1707,7 +1717,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info(`installed terraform-ls`, { bin })
@@ -1790,7 +1800,7 @@ export namespace LSPServer {
if (!ok) return
}
if (ext === "tar.gz") {
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -1803,7 +1813,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info("installed texlab", { bin })
@@ -1995,7 +2005,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -2008,7 +2018,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info("installed tinymist", { bin })

View File

@@ -1,11 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { $ } from "bun"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
const log = Log.create({ service: "vcs" })
@@ -29,13 +29,13 @@ export namespace Vcs {
export type Info = z.infer<typeof Info>
async function currentBranch() {
return $`git rev-parse --abbrev-ref HEAD`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => x.trim())
.catch(() => undefined)
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: Instance.worktree,
})
if (result.exitCode !== 0) return
const text = result.text().trim()
if (!text) return
return text
}
const state = Instance.state(

View File

@@ -1231,42 +1231,6 @@ export namespace Provider {
}
}
async function pick(providerID: string, query: string[]) {
const provider = await state().then((state) => state.providers[providerID])
if (!provider) return
const models = Object.keys(provider.models)
for (const item of query) {
if (providerID === "amazon-bedrock") {
const prefixes = ["global.", "us.", "eu."]
const candidates = models.filter((model) => model.toLowerCase().includes(item.toLowerCase()))
// Model selection priority:
// 1. global. prefix (works everywhere)
// 2. User's region prefix (us., eu.)
// 3. Unprefixed model
const best = candidates.find((model) => model.startsWith("global."))
if (best) return getModel(providerID, best)
const region = provider.options?.region
if (region) {
const prefix = region.split("-")[0]
if (prefix === "us" || prefix === "eu") {
const hit = candidates.find((model) => model.startsWith(`${prefix}.`))
if (hit) return getModel(providerID, hit)
}
}
const bare = candidates.find((model) => !prefixes.some((prefix) => model.startsWith(prefix)))
if (bare) return getModel(providerID, bare)
continue
}
const hit = models.find((model) => model.toLowerCase().includes(item.toLowerCase()))
if (hit) return getModel(providerID, hit)
}
}
export async function getSmallModel(providerID: string) {
const cfg = await Config.get()
@@ -1275,25 +1239,54 @@ export namespace Provider {
return getModel(parsed.providerID, parsed.modelID)
}
let query = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
query = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
// prioritize free models for github copilot
query = ["gpt-5-mini", "claude-haiku-4.5", ...query]
}
const provider = await state().then((state) => state.providers[providerID])
if (provider) {
let priority = [
"claude-haiku-4-5",
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"]
}
if (providerID.startsWith("github-copilot")) {
// prioritize free models for github copilot
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
if (providerID === "amazon-bedrock") {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const model = await pick(providerID, query)
if (model) return model
// Model selection priority:
// 1. global. prefix (works everywhere)
// 2. User's region prefix (us., eu.)
// 3. Unprefixed model
const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return getModel(providerID, globalMatch)
const region = provider.options?.region
if (region) {
const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return getModel(providerID, regionalMatch)
}
}
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return getModel(providerID, unprefixed)
} else {
for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return getModel(providerID, model)
}
}
}
}
// Check if opencode provider is available before using it
const opencodeProvider = await state().then((state) => state.providers["opencode"])
@@ -1304,22 +1297,6 @@ export namespace Provider {
return undefined
}
export async function getExploreModel(providerID: string) {
const model = await pick(providerID, [
"gpt-5.3-codex-spark",
"claude-haiku-4-5",
"claude-haiku-4.5",
"gemini-3-flash",
"minimax-m2.5",
"minimax-m2-5",
"glm-5",
"kimi-k2.5",
"kimi-k2-5",
])
if (model) return model
return undefined
}
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
export function sort(models: Model[]) {
return sortBy(

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -9,12 +8,17 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
@@ -34,13 +38,13 @@ export namespace Snapshot {
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
@@ -55,27 +59,27 @@ export namespace Snapshot {
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
})
.quiet()
.nothrow()
},
nothrow: true,
})
// Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
@@ -89,19 +93,32 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch
if (result.exitCode !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text()
const files = result.text
return {
hash,
files: files
@@ -116,20 +133,37 @@ export namespace Snapshot {
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
@@ -139,19 +173,37 @@ export namespace Snapshot {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
@@ -168,23 +220,36 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.exitCode,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text().trim()
return result.text.trim()
}
export const FileDiff = z
@@ -205,12 +270,24 @@ export namespace Snapshot {
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
const statuses = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) {
if (!line) continue
@@ -220,26 +297,57 @@ export namespace Snapshot {
status.set(file, kind)
}
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.lines()) {
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
.quiet()
.nothrow()
.text()
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile
? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
.quiet()
.nothrow()
.text()
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({
@@ -261,10 +369,22 @@ export namespace Snapshot {
async function add(git: string) {
await syncExclude(git)
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow()
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
}
async function syncExclude(git: string) {
@@ -281,11 +401,10 @@ export namespace Snapshot {
}
async function excludes() {
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
.quiet()
.cwd(Instance.worktree)
.nothrow()
.text()
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())

View File

@@ -5,10 +5,10 @@ import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Glob } from "../util/glob"
import { git } from "@/util/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -49,18 +49,15 @@ export namespace Storage {
}
if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
const result = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const [id] = result
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
if (!id) continue
projectID = id

View File

@@ -7,8 +7,8 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import fs from "fs/promises"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
@@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
log.info("resolved path", { arg, resolved })
if (resolved) {
const normalized =

View File

@@ -5,7 +5,6 @@ import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
@@ -103,30 +102,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const info = msg.info
const model = await iife(async () => {
if (agent.model) return agent.model
if (agent.name !== "explore") {
return {
modelID: info.modelID,
providerID: info.providerID,
}
}
const pick = await Provider.getExploreModel(info.providerID)
if (pick) {
return {
modelID: pick.id,
providerID: pick.providerID,
}
}
return {
modelID: info.modelID,
providerID: info.providerID,
}
})
const model = agent.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.metadata({
title: params.description,

View File

@@ -1,5 +1,5 @@
import { $ } from "bun"
import path from "path"
import { Process } from "./process"
export namespace Archive {
export async function extractZip(zipPath: string, destDir: string) {
@@ -8,9 +8,10 @@ export namespace Archive {
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
} else {
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
}

View File

@@ -25,6 +25,10 @@ export namespace Process {
stderr: Buffer
}
export interface TextResult extends Result {
text: string
}
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
@@ -114,13 +118,33 @@ export namespace Process {
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
const out = {
code,
stdout,
stderr,
}
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
stderr,
}))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
}

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import z from "zod"
@@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { git } from "../util/git"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
@@ -248,14 +249,14 @@ export namespace Worktree {
}
async function sweep(root: string) {
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first
const entries = failed(first)
if (!entries.length) return first
await prune(root, entries)
return $`git clean -ffdx`.quiet().nothrow().cwd(root)
return git(["clean", "-ffdx"], { cwd: root })
}
async function canonical(input: string) {
@@ -274,7 +275,9 @@ export namespace Worktree {
if (await exists(directory)) continue
const ref = `refs/heads/${branch}`
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
})
if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory })
@@ -285,9 +288,9 @@ export namespace Worktree {
async function runStartCommand(directory: string, cmd: string) {
if (process.platform === "win32") {
return $`cmd /c ${cmd}`.nothrow().cwd(directory)
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
}
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
}
type StartKind = "project" | "worktree"
@@ -297,7 +300,7 @@ export namespace Worktree {
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.exitCode === 0) return true
if (ran.code === 0) return true
log.error("worktree start command failed", {
kind,
@@ -344,10 +347,9 @@ export namespace Worktree {
}
export async function createFromInfo(info: Info, startCommand?: string) {
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
cwd: Instance.worktree,
})
if (created.exitCode !== 0) {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
}
@@ -359,7 +361,7 @@ export namespace Worktree {
return () => {
const start = async () => {
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
const populated = await git(["reset", "--hard"], { cwd: info.directory })
if (populated.exitCode !== 0) {
const message = errorText(populated) || "Failed to populate worktree"
log.error("worktree checkout failed", { directory: info.directory, message })
@@ -476,10 +478,10 @@ export namespace Worktree {
const stop = async (target: string) => {
if (!(await exists(target))) return
await $`git fsmonitor--daemon stop`.quiet().nothrow().cwd(target)
await git(["fsmonitor--daemon", "stop"], { cwd: target })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -496,9 +498,11 @@ export namespace Worktree {
}
await stop(entry.path)
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
const removed = await git(["worktree", "remove", "--force", entry.path], {
cwd: Instance.worktree,
})
if (removed.exitCode !== 0) {
const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
@@ -515,7 +519,7 @@ export namespace Worktree {
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
@@ -535,7 +539,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -568,7 +572,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
const remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
@@ -587,18 +591,19 @@ export namespace Worktree {
: ""
const remoteHead = remote
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined }
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const mainCheck = await $`git show-ref --verify --quiet refs/heads/main`.quiet().nothrow().cwd(Instance.worktree)
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
.quiet()
.nothrow()
.cwd(Instance.worktree)
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
cwd: Instance.worktree,
})
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
cwd: Instance.worktree,
})
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
@@ -607,7 +612,7 @@ export namespace Worktree {
}
if (remoteBranch) {
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
@@ -619,7 +624,7 @@ export namespace Worktree {
const worktreePath = entry.path
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
if (resetToTarget.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
}
@@ -629,22 +634,26 @@ export namespace Worktree {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
cwd: worktreePath,
})
if (subReset.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
cwd: worktreePath,
})
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const status = await $`git -c core.fsmonitor=false status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
}

View File

@@ -964,205 +964,6 @@ test("getSmallModel respects config small_model override", async () => {
})
})
test("getExploreModel returns preferred explore model", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"gpt-5-3-codex-spark": {
name: "GPT-5.3 Codex Spark",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"claude-haiku-4.5": {
name: "Claude Haiku 4.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"gemini-3-flash-preview": {
name: "Gemini 3 Flash",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"MiniMax-M2-5": {
name: "MiniMax M2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"GLM-5": {
name: "GLM-5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"Kimi-K2-5": {
name: "Kimi K2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeDefined()
expect(model?.id).toBe("gpt-5-3-codex-spark")
},
})
})
test("getExploreModel matches fallback models case-insensitively", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"MiniMax-M2-5": {
name: "MiniMax M2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"GLM-5": {
name: "GLM-5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
"Kimi-K2-5": {
name: "Kimi K2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeDefined()
expect(model?.id).toBe("MiniMax-M2-5")
},
})
})
test("getExploreModel matches kimi separator variant", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"Kimi-K2-5": {
name: "Kimi K2.5",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeDefined()
expect(model?.id).toBe("Kimi-K2-5")
},
})
})
test("getExploreModel returns undefined when no explore model matches", async () => {
await using tmp = await tmpdir({
config: {
provider: {
"custom-provider": {
name: "Custom Provider",
npm: "@ai-sdk/openai-compatible",
api: "https://api.custom.com/v1",
env: ["CUSTOM_API_KEY"],
models: {
"custom-model": {
name: "Custom Model",
tool_call: true,
limit: {
context: 128000,
output: 4096,
},
},
},
options: {
apiKey: "custom-key",
},
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const model = await Provider.getExploreModel("custom-provider")
expect(model).toBeUndefined()
},
})
})
test("provider.sort prioritizes preferred models", () => {
const models = [
{ id: "random-model", name: "Random" },