Compare commits

..

4 Commits

Author SHA1 Message Date
Adam
160d61bdba chore: cleanup 2026-02-20 05:13:12 -06:00
Adam
c585b9b50d chore: cleanup 2026-02-19 20:42:56 -06:00
Adam
c5089c9851 chore: cleanup 2026-02-19 18:03:00 -06:00
Adam
9c501b1583 wip(app): node-pty 2026-02-19 16:36:40 -06:00
57 changed files with 699 additions and 663 deletions

View File

@@ -25,7 +25,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -75,7 +75,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -109,7 +109,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -136,7 +136,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -160,7 +160,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -184,7 +184,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -217,7 +217,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -246,7 +246,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -262,7 +262,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.9",
"version": "1.2.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -315,7 +315,6 @@
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -331,6 +330,7 @@
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"node-pty": "1.2.0-beta.11",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
@@ -376,7 +376,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -396,7 +396,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.9",
"version": "1.2.7",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -407,7 +407,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -420,7 +420,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -462,7 +462,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"zod": "catalog:",
},
@@ -473,7 +473,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2222,8 +2222,6 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -3298,6 +3296,8 @@
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
"node-pty": ["node-pty@1.2.0-beta.11", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],

View File

@@ -11,8 +11,7 @@
"dev:web": "bun --cwd packages/app dev",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
"postinstall": "bun packages/opencode/script/node-pty-helper.ts",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.9",
"version": "1.2.7",
"description": "",
"type": "module",
"exports": {

View File

@@ -418,7 +418,7 @@ export const SettingsGeneral: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
</div>

View File

@@ -370,7 +370,7 @@ export const SettingsKeybinds: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>

View File

@@ -59,7 +59,7 @@ export const SettingsModels: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">

View File

@@ -177,7 +177,7 @@ export const SettingsPermissions: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>

View File

@@ -132,7 +132,7 @@ export const SettingsProviders: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
</div>

View File

@@ -15,6 +15,40 @@ import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
const FRAME_META = 0
const FRAME_OUTPUT = 1
const FRAME_INPUT = 2
const encoder = new TextEncoder()
const connection = () => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID()
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
const frameInput = (id: string, data: string) => {
const channel = encoder.encode(id)
const body = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + body.length)
out[0] = FRAME_INPUT
out[1] = channel.length
out.set(channel, 2)
out.set(body, 2 + channel.length)
return out
}
const frameOutput = (bytes: Uint8Array, decoder: TextDecoder) => {
if (bytes[0] !== FRAME_OUTPUT) return
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return
if (bytes.length < 2 + size) return
return {
connection: decoder.decode(bytes.subarray(2, 2 + size)),
data: decoder.decode(bytes.subarray(2 + size)),
}
}
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
onSubmit?: () => void
@@ -396,8 +430,10 @@ export const Terminal = (props: TerminalProps) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const connectionID = connection()
const onData = t.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
if (ws?.readyState !== WebSocket.OPEN) return
ws.send(frameInput(connectionID, data))
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@@ -450,6 +486,7 @@ export const Terminal = (props: TerminalProps) => {
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.searchParams.set("connection", connectionID)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? ""
url.password = server.current?.http.password ?? ""
@@ -471,24 +508,33 @@ export const Terminal = (props: TerminalProps) => {
if (closing) return
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
if (bytes[0] === FRAME_META) {
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown; connection?: unknown }
if (typeof meta?.connection === "string" && meta.connection !== connectionID) return
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
} catch (err) {
debugTerminal("invalid websocket control frame", err)
return
}
const frame = frameOutput(bytes, decoder)
if (!frame) return
if (frame.connection !== connectionID) return
if (!frame.data) return
output?.push(frame.data)
cursor += frame.data.length
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
output?.push(data)
cursor += data.length
if (typeof event.data === "string") {
debugTerminal("ignoring unframed websocket output")
}
}
socket.addEventListener("message", handleMessage)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.9",
"version": "1.2.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.9",
"version": "1.2.7",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.9",
"version": "1.2.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.9",
"version": "1.2.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.9",
"version": "1.2.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -40,9 +40,7 @@ use crate::windows::{LoadingWindow, MainWindow};
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
struct ServerReadyData {
url: String,
username: Option<String>,
password: Option<String>,
is_sidecar: bool
}
#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
@@ -607,7 +605,6 @@ async fn initialize(app: AppHandle) {
child,
health_check,
url,
username,
password,
} => {
let app = app.clone();
@@ -634,7 +631,7 @@ async fn initialize(app: AppHandle) {
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData { url, username,password, is_sidecar: true })
Ok(ServerReadyData { url, password })
}
.map(move |res| {
let _ = server_ready_tx.send(res);
@@ -644,9 +641,7 @@ async fn initialize(app: AppHandle) {
ServerConnection::Existing { url } => {
let _ = server_ready_tx.send(Ok(ServerReadyData {
url: url.to_string(),
username: None,
password: None,
is_sidecar: false,
}));
None
}
@@ -724,7 +719,6 @@ enum ServerConnection {
},
CLI {
url: String,
username: Option<String>,
password: Option<String>,
child: CommandChild,
health_check: server::HealthCheck,
@@ -736,15 +730,11 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = &custom_url
&& server::check_health_or_ask_retry(&app, url).await
if let Some(url) = custom_url
&& server::check_health_or_ask_retry(&app, &url).await
{
tracing::info!(%url, "Connected to custom server");
// If the default server is already local, no need to also spawn a sidecar
if server::is_localhost_url(url) {
return ServerConnection::Existing { url: url.clone() };
}
// Remote default server: fall through and also spawn a local sidecar
return ServerConnection::Existing { url: url.clone() };
}
let local_port = get_sidecar_port();
@@ -765,7 +755,6 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
ServerConnection::CLI {
url: local_url,
username: Some("opencode".to_string()),
password: Some(password),
child,
health_check,

View File

@@ -150,7 +150,7 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
return false;
};
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(7));
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
if url_is_localhost(&url) {
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
@@ -178,10 +178,6 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool {
.unwrap_or(false)
}
pub fn is_localhost_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| url_is_localhost(&u))
}
fn url_is_localhost(url: &reqwest::Url) -> bool {
url.host_str().is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")

View File

@@ -35,9 +35,7 @@ export type LoadingWindowComplete = null;
export type ServerReadyData = {
url: string,
username: string | null,
password: string | null,
is_sidecar: boolean,
};
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" };

View File

@@ -23,7 +23,7 @@ import { relaunch } from "@tauri-apps/plugin-process"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { Store } from "@tauri-apps/plugin-store"
import { check, type Update } from "@tauri-apps/plugin-updater"
import { createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../package.json"
import { initI18n, t } from "./i18n"
@@ -31,7 +31,7 @@ import { UPDATER_ENABLED } from "./updater"
import { webviewZoom } from "./webview-zoom"
import "./styles.css"
import { Channel } from "@tauri-apps/api/core"
import { commands, ServerReadyData, type InitStep } from "./bindings"
import { commands, type InitStep } from "./bindings"
import { createMenu } from "./menu"
const root = document.getElementById("root")
@@ -452,19 +452,16 @@ render(() => {
<AppBaseProviders>
<ServerGate>
{(data) => {
const http = {
url: data.url,
username: data.username ?? undefined,
password: data.password ?? undefined,
const server: ServerConnection.Sidecar = {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http: {
url: data().url,
username: "opencode",
password: data().password ?? undefined,
},
}
const server: ServerConnection.Any = data.is_sidecar
? {
displayName: "Local Server",
type: "sidecar",
variant: "base",
http,
}
: { type: "http", http }
function Inner() {
const cmd = useCommand()
@@ -488,8 +485,10 @@ render(() => {
)
}, root!)
type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element }) {
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
return (
@@ -517,7 +516,7 @@ function ServerGate(props: { children: (data: ServerReadyData) => JSX.Element })
</div>
}
>
{(data) => props.children(data())}
{(data) => props.children(data)}
</Show>
</Show>
)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.9",
"version": "1.2.7",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.9"
version = "1.2.7"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.7/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.7/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.7/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.7/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.9/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.7/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.9",
"version": "1.2.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.9",
"version": "1.2.7",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -100,7 +100,7 @@
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"node-pty": "1.2.0-beta.11",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",

View File

@@ -142,6 +142,8 @@ if (!skipInstall) {
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
}
await $`bun script/node-pty-helper.ts`
for (const item of targets) {
const name = [
pkg.name,

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import fs from "node:fs"
import path from "node:path"
import { createRequire } from "node:module"
const req = createRequire(import.meta.url)
const resolve = () => {
try {
return path.dirname(req.resolve("node-pty/package.json"))
} catch {
return
}
}
export const fixNodePtyHelper = () => {
const root = resolve()
if (!root) return []
const files = [
path.join(root, "prebuilds", "darwin-arm64", "spawn-helper"),
path.join(root, "prebuilds", "darwin-x64", "spawn-helper"),
path.join(root, "build", "Release", "spawn-helper"),
path.join(root, "build", "Debug", "spawn-helper"),
]
return files.flatMap((file) => {
if (!fs.existsSync(file)) return []
const mode = fs.statSync(file).mode
const next = mode | 0o111
if (mode === next) return []
fs.chmodSync(file, next)
return [file]
})
}
if (import.meta.main) {
const changed = fixNodePtyHelper()
if (!changed.length) process.exit(0)
console.log(`updated node-pty spawn-helper permissions (${changed.length})`)
for (const file of changed) {
console.log(`- ${file}`)
}
}

View File

@@ -6,10 +6,6 @@ import { Database } from "../../storage/db"
import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
import { inArray } from "drizzle-orm"
import { MessageTable, PartTable } from "../../session/session.sql"
import type { MessageV2 } from "../../session/message-v2"
import { and, eq, gte } from "drizzle-orm"
interface SessionStats {
totalSessions: number
@@ -24,7 +20,7 @@ interface SessionStats {
write: number
}
}
toolUsage: Record<string, { calls: number; errors: number }>
toolUsage: Record<string, number>
modelUsage: Record<
string,
{
@@ -38,7 +34,6 @@ interface SessionStats {
}
}
cost: number
toolUsage: Record<string, { calls: number; errors: number }>
}
>
dateRange: {
@@ -67,11 +62,6 @@ export const StatsCommand = cmd({
.option("models", {
describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
})
.option("model", {
describe: "filter models to show (can be used multiple times)",
type: "array",
string: true,
})
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
@@ -82,20 +72,13 @@ export const StatsCommand = cmd({
const stats = await aggregateSessionStats(args.days, args.project)
let modelLimit: number | undefined
let modelFilter: string[] | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
if (args.model && args.model.length > 0) {
modelFilter = args.model as string[]
modelLimit = modelLimit ?? Infinity
}
displayStats(stats, args.tools, modelLimit, modelFilter)
displayStats(stats, args.tools, modelLimit)
})
},
})
@@ -103,7 +86,14 @@ export const StatsCommand = cmd({
async function getCurrentProject(): Promise<Project.Info> {
return Instance.project
}
async function getAllSessions(): Promise<Session.Info[]> {
const rows = Database.use((db) => db.select().from(SessionTable).all())
return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = (() => {
@@ -122,34 +112,17 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return days
})()
let projectID: string | undefined
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
if (projectFilter !== undefined) {
if (projectFilter === "") {
const currentProject = await getCurrentProject()
projectID = currentProject.id
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
} else {
projectID = projectFilter
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
}
}
const rows = Database.use((db) => {
const conditions = []
if (cutoffTime > 0) {
conditions.push(gte(SessionTable.time_updated, cutoffTime))
}
if (projectID !== undefined) {
conditions.push(eq(SessionTable.project_id, projectID))
}
const baseQuery = db.select().from(SessionTable)
if (conditions.length > 0) {
return baseQuery.where(and(...conditions)).all()
}
return baseQuery.all()
})
const filteredSessions = rows.map((row) => Session.fromRow(row))
const stats: SessionStats = {
totalSessions: filteredSessions.length,
totalMessages: 0,
@@ -189,58 +162,16 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
const sessionTotalTokens: number[] = []
const BATCH_SIZE = 100
const BATCH_SIZE = 20
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
const sessionIds = batch.map((s) => s.id)
// Bulk fetch messages for this batch of sessions
const messageRows = Database.use((db) =>
db.select().from(MessageTable).where(inArray(MessageTable.session_id, sessionIds)).all(),
)
// Group messages by session_id
const messagesBySession = new Map<string, typeof messageRows>()
const messageIds = messageRows.map((r) => r.id)
for (const row of messageRows) {
const msgs = messagesBySession.get(row.session_id) || []
msgs.push(row)
messagesBySession.set(row.session_id, msgs)
}
// Bulk fetch parts for all these messages
let partRows: (typeof PartTable.$inferSelect)[] = []
if (messageIds.length > 0) {
// Chunk message IDs if there are too many for a single IN clause (SQLite has limits)
const PART_BATCH_SIZE = 500
for (let j = 0; j < messageIds.length; j += PART_BATCH_SIZE) {
const idBatch = messageIds.slice(j, j + PART_BATCH_SIZE)
const parts = Database.use((db) =>
db.select().from(PartTable).where(inArray(PartTable.message_id, idBatch)).all(),
)
partRows.push(...parts)
}
}
// Group parts by message_id
const partsByMessage = new Map<string, MessageV2.Part[]>()
for (const row of partRows) {
const parts = partsByMessage.get(row.message_id) || []
parts.push({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id } as MessageV2.Part)
partsByMessage.set(row.message_id, parts)
}
const batchResults = batch.map((session) => {
const rawMessages = messagesBySession.get(session.id) || []
const messages = rawMessages.map((row) => ({
info: { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info,
parts: partsByMessage.get(row.id) || [],
}))
const batchPromises = batch.map(async (session) => {
const messages = await Session.messages({ sessionID: session.id })
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, { calls: number; errors: number }> = {}
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
@@ -254,7 +185,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
}
cost: number
toolUsage: Record<string, { calls: number; errors: number }>
}
> = {}
@@ -268,7 +198,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
toolUsage: {},
}
}
sessionModelUsage[modelKey].messages++
@@ -287,22 +216,11 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0
sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0
}
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
const isError =
part.state && part.state.status === "error" && part.state.error !== "Tool execution aborted"
if (!sessionToolUsage[part.tool]) sessionToolUsage[part.tool] = { calls: 0, errors: 0 }
sessionToolUsage[part.tool].calls++
if (isError) sessionToolUsage[part.tool].errors++
if (!sessionModelUsage[modelKey].toolUsage[part.tool]) {
sessionModelUsage[modelKey].toolUsage[part.tool] = { calls: 0, errors: 0 }
}
sessionModelUsage[modelKey].toolUsage[part.tool].calls++
if (isError) sessionModelUsage[modelKey].toolUsage[part.tool].errors++
}
for (const part of message.parts) {
if (part.type === "tool" && part.tool) {
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
}
}
}
@@ -324,6 +242,8 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
}
})
const batchResults = await Promise.all(batchPromises)
for (const result of batchResults) {
earliestTime = Math.min(earliestTime, result.earliestTime)
latestTime = Math.max(latestTime, result.latestTime)
@@ -338,9 +258,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
stats.totalTokens.cache.write += result.sessionTokens.cache.write
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
if (!stats.toolUsage[tool]) stats.toolUsage[tool] = { calls: 0, errors: 0 }
stats.toolUsage[tool].calls += count.calls
stats.toolUsage[tool].errors += count.errors
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
@@ -349,7 +267,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
messages: 0,
tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } },
cost: 0,
toolUsage: {},
}
}
stats.modelUsage[model].messages += usage.messages
@@ -358,14 +275,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read
stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write
stats.modelUsage[model].cost += usage.cost
for (const [tool, toolUsage] of Object.entries(usage.toolUsage)) {
if (!stats.modelUsage[model].toolUsage[tool]) {
stats.modelUsage[model].toolUsage[tool] = { calls: 0, errors: 0 }
}
stats.modelUsage[model].toolUsage[tool].calls += toolUsage.calls
stats.modelUsage[model].toolUsage[tool].errors += toolUsage.errors
}
}
}
}
@@ -397,7 +306,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number, modelFilter?: string[]) {
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -437,73 +346,43 @@ export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit
console.log()
// Model Usage section
if ((modelLimit !== undefined || modelFilter !== undefined) && Object.keys(stats.modelUsage).length > 0) {
let sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
if (modelFilter && modelFilter.length > 0) {
sortedModels = sortedModels.filter(([model]) => modelFilter.some((filter) => model.includes(filter)))
}
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ MODEL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const modelsToDisplay =
modelLimit === Infinity || modelLimit === undefined ? sortedModels : sortedModels.slice(0, modelLimit)
if (modelsToDisplay.length > 0) {
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ MODEL USAGE │")
for (const [model, usage] of modelsToDisplay) {
console.log(`${model.padEnd(54)}`)
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
console.log(renderRow(" Cache Read", formatNumber(usage.tokens.cache.read)))
console.log(renderRow(" Cache Write", formatNumber(usage.tokens.cache.write)))
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
console.log("├────────────────────────────────────────────────────────┤")
for (const [model, usage] of modelsToDisplay) {
console.log(`${model.padEnd(54)}`)
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
console.log(renderRow(" Cache Read", formatNumber(usage.tokens.cache.read)))
console.log(renderRow(" Cache Write", formatNumber(usage.tokens.cache.write)))
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
if (Object.keys(usage.toolUsage).length > 0) {
console.log(`│ │`)
console.log(`│ Tool Call Rate Error Rate │`)
const totalModelTools = Object.values(usage.toolUsage).reduce((sum, t) => sum + t.calls, 0)
const sortedTools = Object.entries(usage.toolUsage).sort((a, b) => b[1].calls - a[1].calls)
for (const [tool, toolStats] of sortedTools) {
const callRate = ((toolStats.calls / totalModelTools) * 100).toFixed(1) + "%"
const errorRate = toolStats.calls > 0 ? ((toolStats.errors / toolStats.calls) * 100).toFixed(1) + "%" : "0%"
const toolName = tool.length > 22 ? tool.substring(0, 20) + ".." : tool
const paddedTool = toolName.padEnd(24)
const callStr = callRate.padStart(13)
const errStr = errorRate.padStart(15)
console.log(`${paddedTool}${callStr}${errStr}`)
}
}
console.log("├────────────────────────────────────────────────────────┤")
}
// Remove last separator and add bottom border
process.stdout.write("\x1B[1A") // Move up one line
console.log("└────────────────────────────────────────────────────────┘")
}
// Remove last separator and add bottom border
process.stdout.write("\x1B[1A") // Move up one line
console.log("└────────────────────────────────────────────────────────┘")
}
console.log()
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b.calls - a.calls)
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ TOOL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
const maxCount = Math.max(...toolsToDisplay.map(([, toolStats]) => toolStats.calls))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b.calls, 0)
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
for (const [tool, toolStats] of toolsToDisplay) {
const count = toolStats.calls
for (const [tool, count] of toolsToDisplay) {
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
const bar = "█".repeat(barLength)
const percentage = ((count / totalToolUsage) * 100).toFixed(1)

View File

@@ -2,7 +2,8 @@ import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
@@ -82,7 +83,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
return store.history.at(store.index)
},
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))
const entry = clone(item)
let trimmed = false
setStore(
produce((draft) => {

View File

@@ -2,7 +2,8 @@ import path from "path"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { PromptInfo } from "./history"
@@ -52,7 +53,7 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
const stash = clone({ ...entry, timestamp: Date.now() })
let trimmed = false
setStore(
produce((draft) => {

View File

@@ -1,11 +1,10 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { type IPty } from "bun-pty"
import { type IPty } from "node-pty"
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
@@ -15,22 +14,27 @@ export namespace Pty {
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const FRAME_META = 0
const FRAME_OUTPUT = 1
const FRAME_INPUT = 2
const MAX_CONNECTION = 200
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
type Subscriber = {
id: number
token: unknown
connection: string
}
const sockets = new WeakMap<object, number>()
const owners = new WeakMap<object, string>()
let socketCounter = 0
let connectionCounter = 0
const tagSocket = (ws: Socket) => {
if (!ws || typeof ws !== "object") return
@@ -39,33 +43,74 @@ export namespace Pty {
return next
}
const token = (ws: Socket) => {
const data = ws.data
if (!data || typeof data !== "object") return
const events = (data as { events?: unknown }).events
if (events && typeof events === "object") return events
const url = (data as { url?: unknown }).url
if (url && typeof url === "object") return url
return data
const connection = () => {
connectionCounter = (connectionCounter + 1) % Number.MAX_SAFE_INTEGER
return `${Date.now().toString(36)}-${connectionCounter.toString(36)}`
}
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const normalizeConnection = (value?: string) => {
const next = typeof value === "string" ? value.trim() : ""
if (!next) return connection()
if (next.length > MAX_CONNECTION) return connection()
if (encoder.encode(next).length > 255) return connection()
return next
}
const output = (connection: string, data: string) => {
const channel = encoder.encode(connection)
const chunk = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + chunk.length)
out[0] = FRAME_OUTPUT
out[1] = channel.length
out.set(channel, 2)
out.set(chunk, 2 + channel.length)
return out
}
const input = (message: string | Uint8Array | ArrayBuffer) => {
if (typeof message === "string") {
return { data: message }
}
const bytes = message instanceof Uint8Array ? message : new Uint8Array(message)
if (bytes[0] !== FRAME_INPUT) return
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return
if (bytes.length < 2 + size) return
return {
connection: decoder.decode(bytes.subarray(2, 2 + size)),
data: decoder.decode(bytes.subarray(2 + size)),
}
}
// WebSocket control frame: 0x00 + UTF-8 JSON ({ cursor, connection }).
const meta = (cursor: number, connection: string) => {
const json = JSON.stringify({ cursor, connection })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out[0] = FRAME_META
out.set(bytes, 1)
return out
}
const pty = lazy(async () => {
const { spawn } = await import("bun-pty")
return spawn
})
type Spawn = (file: string, args: string | string[], options: unknown) => IPty
let override: Spawn | undefined
let spawn: Spawn | undefined
const pty = async (): Promise<Spawn> => {
if (override) return override
if (spawn) return spawn
const mod = await import("node-pty")
const next = mod.spawn as Spawn
spawn = next
return next
}
export function setSpawn(input?: Spawn) {
override = input
if (input) return
spawn = undefined
}
export const Info = z
.object({
@@ -210,13 +255,8 @@ export namespace Pty {
continue
}
if (sub.token !== undefined && token(ws) !== sub.token) {
session.subscribers.delete(ws)
continue
}
try {
ws.send(chunk)
ws.send(output(sub.connection, chunk))
} catch {
session.subscribers.delete(ws)
}
@@ -292,7 +332,7 @@ export namespace Pty {
}
}
export function connect(id: string, ws: Socket, cursor?: number) {
export function connect(id: string, ws: Socket, cursor?: number, connectionID?: string) {
const session = state().get(id)
if (!session) {
ws.close()
@@ -312,7 +352,11 @@ export namespace Pty {
}
owners.set(ws, id)
session.subscribers.set(ws, { id: socketId, token: token(ws) })
const sub = {
id: socketId,
connection: normalizeConnection(connectionID),
}
session.subscribers.set(ws, sub)
const cleanup = () => {
session.subscribers.delete(ws)
@@ -336,7 +380,7 @@ export namespace Pty {
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
ws.send(output(sub.connection, data.slice(i, i + BUFFER_CHUNK)))
}
} catch {
cleanup()
@@ -346,15 +390,18 @@ export namespace Pty {
}
try {
ws.send(meta(end))
ws.send(meta(end, sub.connection))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
onMessage: (message: string | Uint8Array | ArrayBuffer) => {
const next = input(message)
if (!next?.data) return
if (next.connection && next.connection !== sub.connection) return
session.process.write(next.data)
},
onClose: () => {
log.info("client disconnected from session", { id })

View File

@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { upgradeWebSocket } from "../websocket"
export const PtyRoutes = lazy(() =>
new Hono()
@@ -158,6 +158,12 @@ export const PtyRoutes = lazy(() =>
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
const connection = (() => {
const value = c.req.query("connection")
if (!value) return
if (value.length > 200) return
return value
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
@@ -177,15 +183,20 @@ export const PtyRoutes = lazy(() =>
return {
onOpen(_event, ws) {
const socket = ws.raw
if (!isSocket(socket)) {
if (!isSocket(ws)) {
ws.close()
return
}
handler = Pty.connect(id, socket, cursor)
handler = Pty.connect(id, ws, cursor, connection)
},
onMessage(event) {
if (typeof event.data !== "string") return
if (
typeof event.data !== "string" &&
!(event.data instanceof ArrayBuffer) &&
!(event.data instanceof Uint8Array)
) {
return
}
handler?.onMessage(event.data)
},
onClose() {

View File

@@ -33,7 +33,7 @@ import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { websocket } from "./websocket"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { QuestionRoutes } from "./routes/question"

View File

@@ -0,0 +1 @@
export { createBunWebSocket, upgradeWebSocket, websocket } from "hono/bun"

View File

@@ -11,7 +11,7 @@ import {
tool,
jsonSchema,
} from "ai"
import { mergeDeep, pipe } from "remeda"
import { clone, mergeDeep, pipe } from "remeda"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
@@ -80,11 +80,15 @@ export namespace LLM {
)
const header = system[0]
const original = clone(system)
await Plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
if (system.length === 0) {
system.push(...original)
}
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)

View File

@@ -22,6 +22,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt"
import { defer } from "../util/defer"
import { clone } from "remeda"
import { ToolRegistry } from "../tool/registry"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
@@ -626,9 +627,11 @@ export namespace SessionPrompt {
})
}
const sessionMessages = clone(msgs)
// Ephemerally wrap queued user messages with a reminder to stay on track
if (step > 1 && lastFinished) {
for (const msg of msgs) {
for (const msg of sessionMessages) {
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
for (const part of msg.parts) {
if (part.type !== "text" || part.ignored || part.synthetic) continue
@@ -645,7 +648,7 @@ export namespace SessionPrompt {
}
}
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })
// Build system prompt, adding structured output instruction if needed
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
@@ -661,7 +664,7 @@ export namespace SessionPrompt {
sessionID,
system,
messages: [
...MessageV2.toModelMessages(msgs, model),
...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep
? [
{
@@ -906,12 +909,7 @@ export namespace SessionPrompt {
title: "",
metadata,
output: truncated.content,
attachments: attachments.map((attachment) => ({
...attachment,
id: Identifier.ascending("part"),
sessionID: ctx.sessionID,
messageID: input.processor.message.id,
})),
attachments,
content: result.content, // directly return content to preserve ordering when outputting to model
}
}

View File

@@ -1,9 +1,73 @@
import { describe, expect, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const input = (connection: string, data: string) => {
const channel = encoder.encode(connection)
const body = encoder.encode(data)
const out = new Uint8Array(2 + channel.length + body.length)
out[0] = 2
out[1] = channel.length
out.set(channel, 2)
out.set(body, 2 + channel.length)
return out
}
const output = (connection: string, data: unknown) => {
if (typeof data === "string") return data
if (!(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return ""
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
if (bytes[0] !== 1) return ""
const size = bytes[1]
if (!Number.isSafeInteger(size) || size < 0) return ""
if (bytes.length < 2 + size) return ""
const id = decoder.decode(bytes.subarray(2, 2 + size))
if (id !== connection) return ""
return decoder.decode(bytes.subarray(2 + size))
}
const spawn = () => {
let pid = 1000
return () => {
const data = new Set<(chunk: string) => void>()
const exit = new Set<(event: { exitCode: number }) => void>()
let closed = false
return {
pid: ++pid,
onData: (cb: (chunk: string) => void) => {
data.add(cb)
},
onExit: (cb: (event: { exitCode: number }) => void) => {
exit.add(cb)
},
resize: () => {},
write: (chunk: string) => {
if (closed) return
for (const cb of data) cb(chunk)
},
kill: () => {
if (closed) return
closed = true
for (const cb of exit) cb({ exitCode: 0 })
},
}
}
}
describe("pty", () => {
beforeEach(() => {
Pty.setSpawn(spawn() as unknown as Parameters<typeof Pty.setSpawn>[0])
})
afterEach(() => {
Pty.setSpawn()
})
test("does not leak output when websocket objects are reused", async () => {
await using dir = await tmpdir({ git: true })
@@ -18,9 +82,9 @@ describe("pty", () => {
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
const text = output("conn-a", data)
if (text) outA.push(text)
},
close: () => {
// no-op (simulate abrupt drop)
@@ -28,14 +92,14 @@ describe("pty", () => {
}
// Connect "a" first with ws.
Pty.connect(a.id, ws as any)
Pty.connect(a.id, ws as any, undefined, "conn-a")
// Now "reuse" the same ws object for another connection.
ws.data = { events: { connection: "b" } }
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
const text = output("conn-b", data)
if (text) outB.push(text)
}
Pty.connect(b.id, ws as any)
Pty.connect(b.id, ws as any, undefined, "conn-b")
// Clear connect metadata writes.
outA.length = 0
@@ -54,7 +118,7 @@ describe("pty", () => {
})
})
test("does not leak output when Bun recycles websocket objects before re-connect", async () => {
test("does not leak output when websocket objects are recycled before re-connect", async () => {
await using dir = await tmpdir({ git: true })
await Instance.provide({
@@ -67,9 +131,9 @@ describe("pty", () => {
const ws = {
readyState: 1,
data: { events: { connection: "a" } },
send: (data: unknown) => {
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
const text = output("conn-a", data)
if (text) outA.push(text)
},
close: () => {
// no-op (simulate abrupt drop)
@@ -77,14 +141,14 @@ describe("pty", () => {
}
// Connect "a" first.
Pty.connect(a.id, ws as any)
Pty.connect(a.id, ws as any, undefined, "conn-a")
outA.length = 0
// Simulate Bun reusing the same websocket object for another
// connection before the next onOpen calls Pty.connect.
ws.data = { events: { connection: "b" } }
// Simulate websocket object reuse for another connection before
// the next onOpen calls Pty.connect.
ws.send = (data: unknown) => {
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
const text = output("conn-b", data)
if (text) outB.push(text)
}
Pty.write(a.id, "AAA\n")
@@ -97,4 +161,42 @@ describe("pty", () => {
},
})
})
test("drops input frames that carry a different connection id", 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" })
try {
const out: string[] = []
const ws = {
readyState: 1,
send: (data: unknown) => {
const text = output("conn-a", data)
if (text) out.push(text)
},
close: () => {
// no-op
},
}
const handler = Pty.connect(a.id, ws as any, undefined, "conn-a")
out.length = 0
handler?.onMessage(input("conn-b", "BBB\n"))
await Bun.sleep(100)
expect(out.join("")).not.toContain("BBB")
handler?.onMessage(input("conn-a", "AAA\n"))
await Bun.sleep(100)
expect(out.join("")).toContain("AAA")
} finally {
await Pty.remove(a.id)
}
},
})
})
})

View File

@@ -307,6 +307,7 @@ describe("session.llm.stream", () => {
expect(url.pathname.startsWith("/v1/")).toBe(true)
expect(url.pathname.endsWith("/chat/completions")).toBe(true)
expect(headers.get("Authorization")).toBe("Bearer test-key")
expect(headers.get("User-Agent") ?? "").toMatch(/^opencode\//)
expect(body.model).toBe(resolved.api.id)
expect(body.temperature).toBe(0.4)

View File

@@ -0,0 +1,104 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
test("keeps stored part order stable when file resolution is async", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
})
})
})

View File

@@ -0,0 +1,56 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageV2 } from "../../src/session/message-v2"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
// Verify the URL is properly encoded (# should be %23)
expect(fileParts[0].url).toContain("%23")
// Verify the URL can be correctly converted back to a file path
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
// Verify the file content was read correctly
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})

View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt"
import { tmpdir } from "../fixture/fixture"
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
})
})

View File

@@ -1,211 +0,0 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("session.prompt missing file", () => {
test("does not fail the prompt when a file part is missing", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "does-not-exist.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{ type: "text", text: "please review @does-not-exist.ts" },
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "does-not-exist.ts",
},
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const hasFailure = msg.parts.some(
(part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"),
)
expect(hasFailure).toBe(true)
await Session.remove(session.id)
},
})
})
test("keeps stored part order stable when file resolution is async", async () => {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const missing = path.join(tmp.path, "still-missing.ts")
const msg = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [
{
type: "file",
mime: "text/plain",
url: `file://${missing}`,
filename: "still-missing.ts",
},
{ type: "text", text: "after-file" },
],
})
if (msg.info.role !== "user") throw new Error("expected user message")
const stored = await MessageV2.get({
sessionID: session.id,
messageID: msg.info.id,
})
const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text)
expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true)
expect(text[1]?.includes("Read tool failed to read")).toBe(true)
expect(text[2]).toBe("after-file")
await Session.remove(session.id)
},
})
})
})
describe("session.prompt special characters", () => {
test("handles filenames with # character", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const template = "Read @file#name.txt"
const parts = await SessionPrompt.resolvePromptParts(template)
const fileParts = parts.filter((part) => part.type === "file")
expect(fileParts.length).toBe(1)
expect(fileParts[0].filename).toBe("file#name.txt")
expect(fileParts[0].url).toContain("%23")
const decodedPath = fileURLToPath(fileParts[0].url)
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
const message = await SessionPrompt.prompt({
sessionID: session.id,
parts,
noReply: true,
})
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const textParts = stored.parts.filter((part) => part.type === "text")
const hasContent = textParts.some((part) => part.text.includes("special content"))
expect(hasContent).toBe(true)
await Session.remove(session.id)
},
})
})
})
describe("session.prompt agent variant", () => {
test("applies agent variant only when using agent model", async () => {
const prev = process.env.OPENAI_API_KEY
process.env.OPENAI_API_KEY = "test-openai-key"
try {
await using tmp = await tmpdir({
git: true,
config: {
agent: {
build: {
model: "openai/gpt-5.2",
variant: "xhigh",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const other = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
if (other.info.role !== "user") throw new Error("expected user message")
expect(other.info.variant).toBeUndefined()
const match = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello again" }],
})
if (match.info.role !== "user") throw new Error("expected user message")
expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
expect(match.info.variant).toBe("xhigh")
const override = await SessionPrompt.prompt({
sessionID: session.id,
agent: "build",
noReply: true,
variant: "high",
parts: [{ type: "text", text: "hello third" }],
})
if (override.info.role !== "user") throw new Error("expected user message")
expect(override.info.variant).toBe("high")
await Session.remove(session.id)
},
})
} finally {
if (prev === undefined) delete process.env.OPENAI_API_KEY
else process.env.OPENAI_API_KEY = prev
}
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.9",
"version": "1.2.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.9",
"version": "1.2.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.9",
"version": "1.2.7",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.9",
"version": "1.2.7",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -26,16 +26,13 @@
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
will-change: opacity;
transform: translateZ(0);
}
[data-slot="collapsible-arrow-icon"] {
display: inline-flex;
color: var(--icon-weaker);
transform: translateZ(0) rotate(-90deg);
transform: rotate(-90deg);
transition: transform 0.15s ease;
will-change: transform;
}
&:hover [data-slot="collapsible-arrow"] {
@@ -77,7 +74,7 @@
}
[data-slot="collapsible-arrow-icon"] {
transform: translateZ(0) rotate(0deg);
transform: rotate(0deg);
}
}

View File

@@ -179,7 +179,6 @@
[data-component="text-part"] {
width: 100%;
margin-top: 24px;
[data-slot="text-part-body"] {
margin-top: 0;
@@ -228,18 +227,13 @@
[data-component="reasoning-part"] {
width: 100%;
color: var(--text-base);
line-height: var(--line-height-normal);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
[data-component="markdown"] {
margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
strong,
b {
color: var(--text-weak);
}
p:has(strong) {
margin-top: 24px;

View File

@@ -129,11 +129,6 @@
gap: 8px;
flex-shrink: 0;
[data-slot="collapsible-arrow"] {
margin-left: -6px;
transform: translateY(2px);
}
[data-component="diff-changes"][data-variant="bars"] {
transform: translateY(1px);
}

View File

@@ -117,7 +117,6 @@
--surface-weak: var(--smoke-light-alpha-3);
--surface-weaker: var(--smoke-light-alpha-4);
--surface-strong: #ffffff;
--surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha);
--surface-raised-stronger-non-alpha: var(--white);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);
@@ -376,7 +375,6 @@
--surface-weak: var(--smoke-dark-alpha-4);
--surface-weaker: var(--smoke-dark-alpha-5);
--surface-strong: var(--smoke-dark-alpha-7);
--surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha);
--surface-raised-stronger-non-alpha: var(--smoke-dark-3);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.9",
"version": "1.2.7",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -178,30 +178,7 @@ export default defineConfig({
"network",
"enterprise",
"troubleshooting",
{
label: "Windows",
translations: {
en: "Windows",
ar: "Windows",
"bs-BA": "Windows",
"da-DK": "Windows",
"de-DE": "Windows",
"es-ES": "Windows",
"fr-FR": "Windows",
"it-IT": "Windows",
"ja-JP": "Windows",
"ko-KR": "Windows",
"nb-NO": "Windows",
"pl-PL": "Windows",
"pt-BR": "Windows",
"ru-RU": "Windows",
"th-TH": "Windows",
"tr-TR": "Windows",
"zh-CN": "Windows",
"zh-TW": "Windows",
},
link: "windows-wsl",
},
"windows-wsl",
{
label: "Usage",
translations: {

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.9",
"version": "1.2.7",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -79,32 +79,6 @@ This creates two tools: `math_add` and `math_multiply`.
---
#### Name collisions with built-in tools
Custom tools are keyed by tool name. If a custom tool uses the same name as a built-in tool, the custom tool takes precedence.
For example, this file replaces the built-in `bash` tool:
```ts title=".opencode/tools/bash.ts"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Restricted bash wrapper",
args: {
command: tool.schema.string(),
},
async execute(args) {
return `blocked: ${args.command}`
},
})
```
:::note
Prefer unique names unless you intentionally want to replace a built-in tool. If you want to disable a built in tool but not override it, use [permissions](/docs/permissions).
:::
---
### Arguments
You can use `tool.schema`, which is just [Zod](https://zod.dev), to define argument types.

View File

@@ -135,8 +135,6 @@ Per usare Amazon Bedrock con OpenCode:
2. **Configura l'autenticazione** usando uno dei seguenti metodi:
***
#### Variabili d'ambiente (Avvio rapido)
Imposta una di queste variabili d'ambiente mentre esegui opencode:
@@ -159,8 +157,6 @@ Per usare Amazon Bedrock con OpenCode:
export AWS_REGION=us-east-1
```
***
#### File di configurazione (Consigliato)
Per configurazione specifica del progetto o persistente, usa `opencode.json`:
@@ -188,8 +184,6 @@ Per usare Amazon Bedrock con OpenCode:
Le opzioni del file di configurazione hanno la precedenza sulle variabili d'ambiente.
:::
***
#### Avanzato: VPC Endpoints
Se stai usando VPC endpoints per Bedrock:
@@ -213,16 +207,12 @@ Per usare Amazon Bedrock con OpenCode:
L'opzione `endpoint` è un alias per l'opzione generica `baseURL`, usando terminologia specifica AWS. Se vengono specificati sia `endpoint` sia `baseURL`, `endpoint` ha la precedenza.
:::
***
#### Metodi di autenticazione
- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Crea un utente IAM e genera chiavi di accesso nella Console AWS
- **`AWS_PROFILE`**: Usa profili nominati da `~/.aws/credentials`. Configura prima con `aws configure --profile my-profile` o `aws sso login`
- **`AWS_BEARER_TOKEN_BEDROCK`**: Genera chiavi API a lungo termine dalla console Amazon Bedrock
- **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**: Per EKS IRSA (IAM Roles for Service Accounts) o altri ambienti Kubernetes con federazione OIDC. Queste variabili d'ambiente vengono iniettate automaticamente da Kubernetes quando usi le annotazioni del service account.
***
#### Precedenza autenticazione
Amazon Bedrock usa la seguente priorità di autenticazione:
@@ -240,8 +230,7 @@ Per usare Amazon Bedrock con OpenCode:
```
:::note
Per profili di inferenza personalizzati, usa il nome del modello e del provider nella chiave e imposta la proprietà `id` all'arn. Questo assicura una cache corretta.
:::
Per profili di inferenza personalizzati, usa il nome del modello e del provider nella chiave e imposta la proprietà `id` all'arn. Questo assicura una cache corretta:
```json title="opencode.json"
{
@@ -259,6 +248,8 @@ Per profili di inferenza personalizzati, usa il nome del modello e del provider
}
```
:::
---
### Anthropic
@@ -1170,8 +1161,6 @@ Per usare Kimi K2 di Moonshot AI:
/models
```
---
### MiniMax
1. Vai alla [MiniMax API Console](https://platform.minimax.io/login), crea un account e genera una chiave API.

View File

@@ -3,7 +3,7 @@ title: 엔터프라이즈
description: 조직에서 OpenCode를 안전하게 사용하는 방법입니다.
---
import config from "../../../../config.mjs"
import config from "../../../config.mjs"
export const email = `mailto:${config.email}`
OpenCode Enterprise는 코드와 데이터가 조직의 인프라 밖으로 나가지 않도록 보장하려는 조직을 위한 기능입니다. SSO 및 내부 AI gateway와 연동되는 중앙 config를 사용해 이를 구현할 수 있습니다.

View File

@@ -131,8 +131,6 @@ OpenCode로 Amazon Bedrock을 사용하려면:
2. 다음 방법 중 하나를 사용하여 **설정**합니다:
---
### 환경 변수 (빠른 시작)
OpenCode를 실행하는 동안 다음 환경 변수 중 하나를 설정합니다:
@@ -155,8 +153,6 @@ OpenCode를 실행하는 동안 다음 환경 변수 중 하나를 설정합니
export AWS_REGION=us-east-1
```
---
#### 설정 파일 (권장)
프로젝트별 또는 영구 구성을 위해 `opencode.json`을 사용하십시오.
@@ -185,8 +181,6 @@ OpenCode를 실행하는 동안 다음 환경 변수 중 하나를 설정합니
구성 파일 옵션은 환경 변수보다 우선 순위가 높습니다.
:::
---
#### 고급: VPC 엔드포인트
Bedrock의 VPC 엔드포인트를 사용하는 경우:
@@ -210,8 +204,6 @@ Bedrock의 VPC 엔드포인트를 사용하는 경우:
`endpoint` 옵션은 일반적인 `baseURL` 옵션의 별칭입니다. `endpoint`와 `baseURL` 둘 다 지정된 경우 `endpoint`가 우선합니다.
:::
---
#### 인증 방법
- **`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`**: IAM 사용자 및 AWS 콘솔에서 액세스 키 생성
@@ -219,8 +211,6 @@ Bedrock의 VPC 엔드포인트를 사용하는 경우:
- **`AWS_BEARER_TOKEN_BEDROCK`**: Amazon Bedrock 콘솔에서 임시 API 키 생성
- **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**: EKS IRSA (서비스 계정용 IAM 역할) 또는 다른 Kubernetes 환경의 OIDC 연동. 이 환경 변수는 서비스 계정을 사용할 때 Kubernetes에 의해 자동으로 주입됩니다.
---
#### 인증 우선 순위
Amazon Bedrock은 다음과 같은 인증 우선 순위를 사용합니다.
@@ -239,8 +229,7 @@ Amazon Bedrock은 다음과 같은 인증 우선 순위를 사용합니다.
```
:::note
사용자 정의 추론 프로필(custom inference profiles)의 경우, 키에 모델과 공급자 이름을 사용하고 `id` 속성에 ARN을 설정하십시오. 이렇게 하면 올바른 캐싱이 보장됩니다.
:::
사용자 정의 추론 프로필(custom inference profiles)의 경우, 키에 모델과 공급자 이름을 사용하고 `id` 속성에 ARN을 설정하십시오. 이렇게 하면 올바른 캐싱이 보장됩니다:
```json title="opencode.json"
{
@@ -258,6 +247,8 @@ Amazon Bedrock은 다음과 같은 인증 우선 순위를 사용합니다.
}
```
:::
---
#### Anthropic
@@ -666,8 +657,6 @@ GitLab Duo는 GitLab의 Anthropic 프록시를 통해 기본 도구 호출 기
**OAuth**를 선택하면 브라우저에서 권한 부여를 요청합니다.
---
### 개인 액세스 토큰 사용
1. [GitLab User Settings > Access Tokens](https://gitlab.com/-/user_settings/personal_access_tokens)로 이동

View File

@@ -308,10 +308,6 @@ The `tool` helper creates a custom tool that opencode can call. It takes a Zod s
Your custom tools will be available to opencode alongside built-in tools.
:::note
If a plugin tool uses the same name as a built-in tool, the plugin tool takes precedence.
:::
---
### Logging

View File

@@ -135,8 +135,6 @@ To use Amazon Bedrock with OpenCode:
2. **Configure authentication** using one of the following methods:
***
#### Environment Variables (Quick Start)
Set one of these environment variables while running opencode:
@@ -159,8 +157,6 @@ To use Amazon Bedrock with OpenCode:
export AWS_REGION=us-east-1
```
***
#### Configuration File (Recommended)
For project-specific or persistent configuration, use `opencode.json`:
@@ -188,8 +184,6 @@ To use Amazon Bedrock with OpenCode:
Configuration file options take precedence over environment variables.
:::
***
#### Advanced: VPC Endpoints
If you're using VPC endpoints for Bedrock:
@@ -213,16 +207,12 @@ To use Amazon Bedrock with OpenCode:
The `endpoint` option is an alias for the generic `baseURL` option, using AWS-specific terminology. If both `endpoint` and `baseURL` are specified, `endpoint` takes precedence.
:::
***
#### Authentication Methods
- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Create an IAM user and generate access keys in the AWS Console
- **`AWS_PROFILE`**: Use named profiles from `~/.aws/credentials`. First configure with `aws configure --profile my-profile` or `aws sso login`
- **`AWS_BEARER_TOKEN_BEDROCK`**: Generate long-term API keys from the Amazon Bedrock console
- **`AWS_WEB_IDENTITY_TOKEN_FILE` / `AWS_ROLE_ARN`**: For EKS IRSA (IAM Roles for Service Accounts) or other Kubernetes environments with OIDC federation. These environment variables are automatically injected by Kubernetes when using service account annotations.
***
#### Authentication Precedence
Amazon Bedrock uses the following authentication priority:
@@ -240,8 +230,7 @@ To use Amazon Bedrock with OpenCode:
```
:::note
For custom inference profiles, use the model and provider name in the key and set the `id` property to the arn. This ensures correct caching.
:::
For custom inference profiles, use the model and provider name in the key and set the `id` property to the arn. This ensures correct caching:
```json title="opencode.json"
{
@@ -259,6 +248,8 @@ For custom inference profiles, use the model and provider name in the key and se
}
```
:::
---
### Anthropic

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.9",
"version": "1.2.7",
"publisher": "sst-dev",
"repository": {
"type": "git",