mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-20 07:44:23 +00:00
Compare commits
3 Commits
beta
...
snapshot-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c585b9b50d | ||
|
|
c5089c9851 | ||
|
|
9c501b1583 |
18
.github/workflows/publish.yml
vendored
18
.github/workflows/publish.yml
vendored
@@ -49,16 +49,14 @@ jobs:
|
||||
run: |
|
||||
./script/version.ts
|
||||
env:
|
||||
GH_TOKEN: ${{ (github.ref_name == 'beta' && secrets.BRENDAN_GITHUB_TOKEN) || github.token }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
OPENCODE_BUMP: ${{ inputs.bump }}
|
||||
OPENCODE_VERSION: ${{ inputs.version }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GH_REPO: ${{ (github.ref_name == 'beta' && 'brendonovich/opencode-desktop-beta') || github.repository }}
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release: ${{ steps.version.outputs.release }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
repo: ${{ steps.version.outputs.repo }}
|
||||
|
||||
build-cli:
|
||||
needs: version
|
||||
@@ -76,8 +74,8 @@ jobs:
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ (github.ref_name != 'beta' && needs.version.outputs.version) || '' }}
|
||||
OPENCODE_RELEASE: ${{ (github.ref_name != 'beta' && needs.version.outputs.release) || '' }}
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -198,17 +196,14 @@ jobs:
|
||||
projectPath: packages/desktop
|
||||
uploadWorkflowArtifacts: true
|
||||
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
|
||||
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
|
||||
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
|
||||
updaterJsonPreferNsis: true
|
||||
releaseId: ${{ needs.version.outputs.release }}
|
||||
tagName: ${{ needs.version.outputs.tag }}
|
||||
releaseDraft: true
|
||||
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
|
||||
owner: ${{ (github.ref_name == 'beta' && 'brendonovich') || '' }}
|
||||
repo: ${{ (github.ref_name == 'beta' && 'opencode-desktop-beta') || '' }}
|
||||
releaseCommitish: ${{ (github.ref_name == 'beta' && github.sha) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ (github.ref_name == 'beta' && secrets.BRENDAN_GITHUB_TOKEN) || secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -284,6 +279,5 @@ jobs:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
GITHUB_TOKEN: ${{ (github.ref_name == 'beta' && secrets.BRENDAN_GITHUB_TOKEN) || steps.committer.outputs.token }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,4 +27,3 @@ target
|
||||
opencode-dev
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
36
bun.lock
36
bun.lock
@@ -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.1.0",
|
||||
"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.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenCode Beta",
|
||||
"identifier": "ai.opencode.desktop.beta",
|
||||
"bundle": {
|
||||
"createUpdaterArtifacts": true,
|
||||
"linux": {
|
||||
"rpm": {
|
||||
"compression": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK",
|
||||
"endpoints": ["https://github.com/brendonovich/opencode-desktop-beta/releases/latest/download/latest.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" };
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.1.0",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
packages/opencode/script/node-pty-helper.ts
Normal file
45
packages/opencode/script/node-pty-helper.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@ export namespace Agent {
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
edit: "ask",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
||||
@@ -365,11 +365,6 @@ export const RunCommand = cmd({
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
permission: "edit",
|
||||
action: "allow",
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
|
||||
function title() {
|
||||
|
||||
@@ -457,7 +457,6 @@ function App() {
|
||||
{
|
||||
title: "Toggle MCPs",
|
||||
value: "mcp.list",
|
||||
search: "toggle mcps",
|
||||
category: "Agent",
|
||||
slash: {
|
||||
name: "mcps",
|
||||
@@ -533,9 +532,8 @@ function App() {
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: mode() === "dark" ? "Light mode" : "Dark mode",
|
||||
title: "Toggle appearance",
|
||||
value: "theme.switch_mode",
|
||||
search: "toggle appearance",
|
||||
onSelect: (dialog) => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
@@ -574,7 +572,6 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle debug panel",
|
||||
search: "toggle debug",
|
||||
category: "System",
|
||||
value: "app.debug",
|
||||
onSelect: (dialog) => {
|
||||
@@ -584,7 +581,6 @@ function App() {
|
||||
},
|
||||
{
|
||||
title: "Toggle console",
|
||||
search: "toggle console",
|
||||
category: "System",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
@@ -625,7 +621,6 @@ function App() {
|
||||
{
|
||||
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
|
||||
value: "terminal.title.toggle",
|
||||
search: "toggle terminal title",
|
||||
keybind: "terminal_title_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
@@ -641,7 +636,6 @@ function App() {
|
||||
{
|
||||
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
|
||||
value: "app.toggle.animations",
|
||||
search: "toggle animations",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("animations_enabled", !kv.get("animations_enabled", true))
|
||||
@@ -651,7 +645,6 @@ function App() {
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
search: "toggle diff wrapping",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
const current = kv.get("diff_wrap_mode", "word")
|
||||
|
||||
@@ -7,27 +7,6 @@ import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import type { Provider } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function pickLatest(models: [string, Provider["models"][string]][]) {
|
||||
const picks: Record<string, [string, Provider["models"][string]]> = {}
|
||||
for (const item of models) {
|
||||
const model = item[0]
|
||||
const info = item[1]
|
||||
const key = info.family ?? model
|
||||
const prev = picks[key]
|
||||
if (!prev) {
|
||||
picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (info.release_date !== prev[1].release_date) {
|
||||
if (info.release_date > prev[1].release_date) picks[key] = item
|
||||
continue
|
||||
}
|
||||
if (model > prev[0]) picks[key] = item
|
||||
}
|
||||
return Object.values(picks)
|
||||
}
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -42,7 +21,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [all, setAll] = createSignal(false)
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
@@ -94,8 +72,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) => {
|
||||
const items = pipe(
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
@@ -126,9 +104,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
)
|
||||
return items
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
@@ -177,13 +154,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_show_all_toggle?.[0],
|
||||
title: all() ? "Show latest only" : "Show all models",
|
||||
onTrigger: () => {
|
||||
setAll((value) => !value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -77,7 +77,6 @@ export function Prompt(props: PromptProps) {
|
||||
const renderer = useRenderer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -171,17 +170,6 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -1008,30 +996,23 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
<Show when={showVariant()}>
|
||||
<text fg={theme.textMuted}>·</text>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -25,7 +25,6 @@ import { createSimpleContext } from "./helper"
|
||||
import type { Snapshot } from "@/snapshot"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { useKV } from "./kv"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
@@ -104,8 +103,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
@@ -130,13 +127,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
@@ -451,7 +441,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
get ready() {
|
||||
return store.status !== "loading"
|
||||
},
|
||||
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
|
||||
@@ -46,7 +46,6 @@ export function Home() {
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
search: "toggle tips",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -525,7 +525,6 @@ export function Session() {
|
||||
{
|
||||
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
|
||||
value: "session.sidebar.toggle",
|
||||
search: "toggle sidebar",
|
||||
keybind: "sidebar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -540,7 +539,6 @@ export function Session() {
|
||||
{
|
||||
title: conceal() ? "Disable code concealment" : "Enable code concealment",
|
||||
value: "session.toggle.conceal",
|
||||
search: "toggle code concealment",
|
||||
keybind: "messages_toggle_conceal" as any,
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -551,7 +549,6 @@ export function Session() {
|
||||
{
|
||||
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
|
||||
value: "session.toggle.timestamps",
|
||||
search: "toggle timestamps",
|
||||
category: "Session",
|
||||
slash: {
|
||||
name: "timestamps",
|
||||
@@ -565,7 +562,6 @@ export function Session() {
|
||||
{
|
||||
title: showThinking() ? "Hide thinking" : "Show thinking",
|
||||
value: "session.toggle.thinking",
|
||||
search: "toggle thinking",
|
||||
keybind: "display_thinking",
|
||||
category: "Session",
|
||||
slash: {
|
||||
@@ -580,7 +576,6 @@ export function Session() {
|
||||
{
|
||||
title: showDetails() ? "Hide tool details" : "Show tool details",
|
||||
value: "session.toggle.actions",
|
||||
search: "toggle tool details",
|
||||
keybind: "tool_details",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
@@ -589,9 +584,8 @@ export function Session() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
|
||||
title: "Toggle session scrollbar",
|
||||
value: "session.toggle.scrollbar",
|
||||
search: "toggle session scrollbar",
|
||||
keybind: "scrollbar_toggle",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => {
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
@@ -86,8 +85,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -789,7 +789,6 @@ export namespace Config {
|
||||
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
||||
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
||||
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
||||
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
||||
@@ -830,12 +829,7 @@ export namespace Config {
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
|
||||
permission_auto_accept_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("Toggle auto-accept mode for permissions"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
|
||||
@@ -50,7 +50,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
|
||||
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
|
||||
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
|
||||
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Worktree } from "../../worktree"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { Project } from "../../project/project"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Session } from "../../session"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -185,65 +184,6 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/session",
|
||||
describeRoute({
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
operationId: "experimental.session.list",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of sessions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Session.GlobalInfo.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
|
||||
cursor: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
|
||||
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
|
||||
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
|
||||
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: query.archived,
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const hasMore = sessions.length > limit
|
||||
const list = hasMore ? sessions.slice(0, limit) : sessions
|
||||
if (hasMore && list.length > 0) {
|
||||
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
|
||||
}
|
||||
return c.json(list)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/resource",
|
||||
describeRoute({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
1
packages/opencode/src/server/websocket.ts
Normal file
1
packages/opencode/src/server/websocket.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createBunWebSocket, upgradeWebSocket, websocket } from "hono/bun"
|
||||
@@ -10,10 +10,8 @@ import { Flag } from "../flag/flag"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -156,24 +154,6 @@ export namespace Session {
|
||||
})
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const ProjectInfo = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
worktree: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "ProjectSummary",
|
||||
})
|
||||
export type ProjectInfo = z.output<typeof ProjectInfo>
|
||||
|
||||
export const GlobalInfo = Info.extend({
|
||||
project: ProjectInfo.nullable(),
|
||||
}).meta({
|
||||
ref: "GlobalSession",
|
||||
})
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define(
|
||||
"session.created",
|
||||
@@ -564,75 +544,6 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
export function* listGlobal(input?: {
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
archived?: boolean
|
||||
}) {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
if (input?.directory) {
|
||||
conditions.push(eq(SessionTable.directory, input.directory))
|
||||
}
|
||||
if (input?.roots) {
|
||||
conditions.push(isNull(SessionTable.parent_id))
|
||||
}
|
||||
if (input?.start) {
|
||||
conditions.push(gte(SessionTable.time_updated, input.start))
|
||||
}
|
||||
if (input?.cursor) {
|
||||
conditions.push(lt(SessionTable.time_updated, input.cursor))
|
||||
}
|
||||
if (input?.search) {
|
||||
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
}
|
||||
if (!input?.archived) {
|
||||
conditions.push(isNull(SessionTable.time_archived))
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 100
|
||||
|
||||
const rows = Database.use((db) => {
|
||||
const query =
|
||||
conditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
: db.select().from(SessionTable)
|
||||
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
|
||||
})
|
||||
|
||||
const ids = [...new Set(rows.map((row) => row.project_id))]
|
||||
const projects = new Map<string, ProjectInfo>()
|
||||
|
||||
if (ids.length > 0) {
|
||||
const items = Database.use((db) =>
|
||||
db
|
||||
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(inArray(ProjectTable.id, ids))
|
||||
.all(),
|
||||
)
|
||||
for (const item of items) {
|
||||
projects.set(item.id, {
|
||||
id: item.id,
|
||||
name: item.name ?? undefined,
|
||||
worktree: item.worktree,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const project = projects.get(row.project_id) ?? null
|
||||
yield { ...fromRow(row), project }
|
||||
}
|
||||
}
|
||||
|
||||
export const children = fn(Identifier.schema("session"), async (parentID) => {
|
||||
const project = Instance.project
|
||||
const rows = Database.use((db) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1322,7 +1320,33 @@ export namespace SessionPrompt {
|
||||
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
|
||||
if (!userMessage) return input.messages
|
||||
|
||||
// Plan mode logic
|
||||
// Original logic when experimental plan mode is disabled
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
|
||||
if (input.agent.name === "plan") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: PROMPT_PLAN,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
|
||||
if (wasPlan && input.agent.name === "build") {
|
||||
userMessage.parts.push({
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: userMessage.info.id,
|
||||
sessionID: userMessage.info.sessionID,
|
||||
type: "text",
|
||||
text: BUILD_SWITCH,
|
||||
synthetic: true,
|
||||
})
|
||||
}
|
||||
return input.messages
|
||||
}
|
||||
|
||||
// New plan mode logic when flag is enabled
|
||||
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
|
||||
|
||||
// Switching from plan mode to build mode
|
||||
|
||||
@@ -117,7 +117,7 @@ export namespace ToolRegistry {
|
||||
ApplyPatchTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...(Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
|
||||
expect(build).toBeDefined()
|
||||
expect(build?.mode).toBe("primary")
|
||||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
},
|
||||
})
|
||||
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("Session.listGlobal", () => {
|
||||
test("lists sessions across projects with project metadata", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
|
||||
const firstSession = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => Session.create({ title: "first-session" }),
|
||||
})
|
||||
const secondSession = await Instance.provide({
|
||||
directory: second.path,
|
||||
fn: async () => Session.create({ title: "second-session" }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
expect(ids).toContain(secondSession.id)
|
||||
|
||||
const firstProject = Project.get(firstSession.projectID)
|
||||
const secondProject = Project.get(secondSession.projectID)
|
||||
|
||||
const firstItem = sessions.find((session) => session.id === firstSession.id)
|
||||
const secondItem = sessions.find((session) => session.id === secondSession.id)
|
||||
|
||||
expect(firstItem?.project?.id).toBe(firstProject?.id)
|
||||
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
|
||||
expect(secondItem?.project?.id).toBe(secondProject?.id)
|
||||
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
|
||||
})
|
||||
|
||||
test("excludes archived sessions by default", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const archived = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "archived-session" }),
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
|
||||
})
|
||||
|
||||
const sessions = [...Session.listGlobal({ limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
})
|
||||
|
||||
test("supports cursor pagination", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const first = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-one" }),
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => Session.create({ title: "page-two" }),
|
||||
})
|
||||
|
||||
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
expect(ids).not.toContain(second.id)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
104
packages/opencode/test/session/prompt-missing-file.test.ts
Normal file
104
packages/opencode/test/session/prompt-missing-file.test.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
56
packages/opencode/test/session/prompt-special-chars.test.ts
Normal file
56
packages/opencode/test/session/prompt-special-chars.test.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
68
packages/opencode/test/session/prompt-variant.test.ts
Normal file
68
packages/opencode/test/session/prompt-variant.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
@@ -13,15 +13,15 @@
|
||||
"./client": "./src/client.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./v2": {
|
||||
"types": "./dist/v2/index.d.ts",
|
||||
"types": "./dist/src/v2/index.d.ts",
|
||||
"default": "./src/v2/index.ts"
|
||||
},
|
||||
"./v2/client": {
|
||||
"types": "./dist/v2/client.d.ts",
|
||||
"types": "./dist/src/v2/client.d.ts",
|
||||
"default": "./src/v2/client.ts"
|
||||
},
|
||||
"./v2/gen/client": {
|
||||
"types": "./dist/v2/gen/client/index.d.ts",
|
||||
"types": "./dist/src/v2/gen/client/index.d.ts",
|
||||
"default": "./src/v2/gen/client/index.ts"
|
||||
},
|
||||
"./v2/server": "./src/v2/server.ts"
|
||||
|
||||
@@ -1067,10 +1067,6 @@ export type KeybindsConfig = {
|
||||
* Toggle model favorite status
|
||||
*/
|
||||
model_favorite_toggle?: string
|
||||
/**
|
||||
* Toggle showing all models
|
||||
*/
|
||||
model_show_all_toggle?: string
|
||||
/**
|
||||
* Share current session
|
||||
*/
|
||||
@@ -1187,10 +1183,6 @@ export type KeybindsConfig = {
|
||||
* Previous agent
|
||||
*/
|
||||
agent_cycle_reverse?: string
|
||||
/**
|
||||
* Toggle auto-accept mode for permissions
|
||||
*/
|
||||
permission_auto_accept_toggle?: string
|
||||
/**
|
||||
* Cycle model variants
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"declaration": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["es2022", "dom", "dom.iterable"],
|
||||
"composite": true,
|
||||
"rootDir": "src"
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -600,3 +600,4 @@ These environment variables enable experimental features that may change or be r
|
||||
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features |
|
||||
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable experimental LSP type checking |
|
||||
| `OPENCODE_EXPERIMENTAL_MARKDOWN` | boolean | Enable experimental markdown features |
|
||||
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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를 사용해 이를 구현할 수 있습니다.
|
||||
|
||||
@@ -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)로 이동
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,16 +57,13 @@ await $`bun install`
|
||||
await import(`../packages/sdk/js/script/build.ts`)
|
||||
|
||||
if (Script.release) {
|
||||
if (Script.channel === "latest") {
|
||||
await $`git commit -am "release: v${Script.version}"`
|
||||
await $`git tag v${Script.version}`
|
||||
await $`git fetch origin`
|
||||
await $`git cherry-pick HEAD..origin/dev`.nothrow()
|
||||
await $`git push origin HEAD --tags --no-verify --force-with-lease`
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
}
|
||||
|
||||
await $`gh release edit v${Script.version} --draft=false --repo ${process.env.GH_REPO}`
|
||||
await $`git commit -am "release: v${Script.version}"`
|
||||
await $`git tag v${Script.version}`
|
||||
await $`git fetch origin`
|
||||
await $`git cherry-pick HEAD..origin/dev`.nothrow()
|
||||
await $`git push origin HEAD --tags --no-verify --force-with-lease`
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
await $`gh release edit v${Script.version} --draft=false`
|
||||
}
|
||||
|
||||
console.log("\n=== cli ===\n")
|
||||
|
||||
@@ -17,13 +17,6 @@ if (!Script.preview) {
|
||||
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
} else if (Script.channel === "beta") {
|
||||
await $`gh release create v${Script.version} -d --title "v${Script.version}" --repo ${process.env.GH_REPO}`
|
||||
const release =
|
||||
await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json()
|
||||
output.push(`release=${release.databaseId}`)
|
||||
output.push(`tag=${release.tagName}`)
|
||||
output.push(`repo=${process.env.GH_REPO}`)
|
||||
}
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user