Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline aaa14d4865 fix: ensure plurals are properly handled 2026-01-12 15:19:17 -06:00
100 changed files with 554 additions and 1535 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
- To test opencode in `packages/opencode`, run `bun dev`.
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- To test opencode in the `packages/opencode` directory you can run `bun dev`
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- the default branch in this repo is `dev`
-11
View File
@@ -1,11 +0,0 @@
# Reporting Security Issues
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/anomalyco/opencode/security/advisories/new) tab.
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
## Escalation
If you do not receive an acknowledgement of your report within 6 business days, you may send an email to security@anoma.ly
-1
View File
@@ -199,4 +199,3 @@
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
+12 -9
View File
@@ -1,16 +1,19 @@
## Style Guide
- Keep things in one function unless composable or reusable
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Try to keep things in one function unless composable or reusable
- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID using `any` type
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
# Avoid let statements
We don't like `let` statements, especially combined with if/else statements.
Prefer `const`.
we don't like let statements, especially combined with if/else statements.
prefer const
This is bad:
Good:
@@ -29,7 +32,7 @@ else foo = 2
# Avoid else statements
Prefer early returns or using an `iife` to avoid else statements.
Prefer early returns or even using `iife` to avoid else statements
Good:
+15 -21
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -99,7 +99,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -126,7 +126,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -150,7 +150,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -174,7 +174,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -232,7 +232,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -248,7 +248,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.17",
"version": "1.1.14",
"bin": {
"opencode": "./bin/opencode",
},
@@ -351,7 +351,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -371,7 +371,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.17",
"version": "1.1.14",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -382,7 +382,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -395,7 +395,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -407,7 +407,6 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "catalog:",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
@@ -435,7 +434,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"zod": "catalog:",
},
@@ -446,7 +445,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -508,7 +507,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "5.0.119",
"diff": "8.0.2",
"dompurify": "3.3.1",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -1830,8 +1828,6 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="],
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
@@ -2284,8 +2280,6 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
+5 -6
View File
@@ -81,13 +81,12 @@ This will walk you through installing the GitHub app, creating the workflow, and
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
persist-credentials: false
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run opencode
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
-1
View File
@@ -163,7 +163,6 @@ new sst.cloudflare.x.SolidStart("Console", {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev
? [
+2 -2
View File
@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=",
"aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ="
"x86_64-linux": "sha256-8nur5CuUCSV/SzD16hNXVoIlKsiPBXDzCnoITK0IhC4=",
"aarch64-darwin": "sha256-vD1g9dviI2nMBTTPwI87sK01hSZ+cdnmb1V72AdJYq4="
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
if [ ! -f "$HASH_FILE" ]; then
cat >"$HASH_FILE" <<EOF
{
"nodeModules": {}
"nodeModules": "$DUMMY"
}
EOF
fi
@@ -111,7 +111,7 @@ fi
write_node_modules_hash "$CORRECT_HASH"
jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
-1
View File
@@ -36,7 +36,6 @@
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"ai": "5.0.119",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.17",
"version": "1.1.14",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -33,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
__OPENCODE__?: { updaterEnabled?: boolean }
}
}
@@ -7,8 +7,6 @@ import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models"
@@ -37,12 +35,6 @@ const ModelList: Component<{
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
groupHeader={(group) => (
<div class="flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={group.items[0].provider.id as IconName} />
<span>{group.category}</span>
</div>
)}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
@@ -60,8 +52,7 @@ const ModelList: Component<{
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3 pl-1 text-13-regular">
<ProviderIcon data-slot="list-item-extra-icon" id={i.provider.id as IconName} />
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
@@ -5,7 +5,6 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
@@ -117,10 +116,6 @@ export function DialogSelectServer() {
select(value, true)
}
async function handleRemove(url: string) {
server.remove(url)
}
return (
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
<div class="flex flex-col gap-4 pb-4">
@@ -135,33 +130,20 @@ export function DialogSelectServer() {
}}
>
{(i) => (
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
<Show when={current() !== i && server.list.includes(i)}>
<IconButton
icon="circle-x"
variant="ghost"
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
onClick={(e) => {
e.stopPropagation()
handleRemove(i)
}}
/>
</Show>
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
)}
</List>
+2 -10
View File
@@ -33,8 +33,6 @@ import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
@@ -1562,9 +1560,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
@@ -1574,9 +1569,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ModelSelectorPopover>
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost">
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
@@ -1591,10 +1583,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
onClick={() => local.model.variant.cycle()}
>
{local.model.variant.current() ?? "Default"}
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
</Button>
</TooltipKeybind>
</Show>
+3 -6
View File
@@ -100,12 +100,9 @@ export const Terminal = (props: TerminalProps) => {
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
const socket = new WebSocket(
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
ws = socket
const t = new mod.Terminal({
+1 -2
View File
@@ -9,13 +9,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
name: "GlobalSDK",
init: () => {
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
fetch: platform.fetch,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
@@ -95,6 +93,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
stop()
})
const platform = usePlatform()
const sdk = createOpencodeClient({
baseUrl: server.url,
fetch: platform.fetch,
-4
View File
@@ -26,7 +26,6 @@ import { ErrorPage, type InitError } from "../pages/error"
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
type State = {
status: "loading" | "partial" | "complete"
@@ -65,7 +64,6 @@ type State = {
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
error?: InitError
@@ -141,7 +139,6 @@ function createGlobalSync() {
const [store, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
@@ -399,7 +396,6 @@ function createGlobalSync() {
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
+1 -15
View File
@@ -8,7 +8,6 @@ import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
title: string
titleNumber: number
rows?: number
cols?: number
buffer?: string
@@ -43,20 +42,8 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
const existingTitleNumbers = new Set(
store.all.map((pty) => {
const match = pty.titleNumber
return match
}),
)
let nextNumber = 1
while (existingTitleNumbers.has(nextNumber)) {
nextNumber++
}
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
.create({ title: `Terminal ${store.all.length + 1}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
@@ -65,7 +52,6 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
{
id,
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
},
])
setStore("active", id)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.17",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
@@ -0,0 +1,24 @@
import { useSession } from "@solidjs/start/http"
export interface AuthSession {
account?: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: "0".repeat(32),
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,
},
})
}
+1 -26
View File
@@ -5,38 +5,13 @@ import { redirect } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { createClient } from "@openauthjs/openauth/client"
import { useAuthSession } from "./auth.session"
export const AuthClient = createClient({
clientID: "app",
issuer: import.meta.env.VITE_AUTH_URL,
})
import { useSession } from "@solidjs/start/http"
import { Resource } from "@opencode-ai/console-resource"
export interface AuthSession {
account?: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: Resource.ZEN_SESSION_SECRET.value,
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,
},
})
}
export const getActor = async (workspace?: string): Promise<Actor.Info> => {
"use server"
const evt = getRequestEvent()
@@ -1,7 +1,7 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -1,6 +1,6 @@
import { redirect } from "@solidjs/router"
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth"
import { useAuthSession } from "~/context/auth.session"
export async function GET(event: APIEvent) {
const auth = await useAuthSession()
@@ -1,5 +1,5 @@
import { APIEvent } from "@solidjs/start"
import { useAuthSession } from "~/context/auth"
import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) {
const session = await useAuthSession()
@@ -131,188 +131,6 @@
text-decoration: none;
}
[data-slot="pricing"] {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
max-width: 540px;
padding: 0 20px;
@media (min-width: 768px) {
padding: 0;
}
}
[data-slot="pricing-card"] {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
text-decoration: none;
transition: border-color 0.15s ease;
background: transparent;
cursor: pointer;
text-align: left;
&:hover {
border-color: rgba(255, 255, 255, 0.35);
}
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
}
[data-slot="selected-plan"] {
display: flex;
flex-direction: column;
gap: 32px;
width: fit-content;
max-width: calc(100% - 40px);
margin: 0 auto;
}
[data-slot="selected-card"] {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.17);
border-radius: 4px;
width: fit-content;
[data-slot="icon"] {
color: rgba(255, 255, 255, 0.59);
}
[data-slot="price"] {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
[data-slot="amount"] {
color: rgba(255, 255, 255, 0.92);
font-size: 24px;
font-weight: 500;
}
[data-slot="period"] {
color: rgba(255, 255, 255, 0.59);
font-size: 14px;
}
[data-slot="multiplier"] {
color: rgba(255, 255, 255, 0.39);
font-size: 14px;
&::before {
content: "·";
margin-right: 8px;
}
}
[data-slot="terms"] {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left;
li {
color: rgba(255, 255, 255, 0.59);
font-size: 13px;
line-height: 1.5;
padding-left: 16px;
position: relative;
white-space: nowrap;
&::before {
content: "▪";
position: absolute;
left: 0;
color: rgba(255, 255, 255, 0.39);
}
}
}
[data-slot="actions"] {
display: flex;
gap: 16px;
margin-top: 8px;
button,
a {
flex: 1;
display: inline-flex;
height: 48px;
padding: 0 16px;
justify-content: center;
align-items: center;
border-radius: 4px;
font-family: var(--font-mono);
font-size: 16px;
font-weight: 400;
text-decoration: none;
cursor: pointer;
}
[data-slot="cancel"] {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.92);
&:hover {
border-color: rgba(255, 255, 255, 0.35);
}
}
[data-slot="continue"] {
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.17);
color: rgba(255, 255, 255, 0.59);
&:hover {
background: rgba(255, 255, 255, 0.25);
}
}
}
}
[data-slot="fine-print"] {
color: rgba(255, 255, 255, 0.39);
text-align: center;
@@ -320,12 +138,6 @@
font-style: normal;
font-weight: 400;
line-height: 160%; /* 20.8px */
font-style: italic;
a {
color: rgba(255, 255, 255, 0.39);
text-decoration: underline;
}
}
}
}
+15 -103
View File
@@ -1,54 +1,11 @@
import { A, createAsync, useSearchParams } from "@solidjs/router"
import { A, createAsync } from "@solidjs/router"
import "./index.css"
import { Title } from "@solidjs/meta"
import { github } from "~/lib/github"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createMemo, Match, Switch } from "solid-js"
import { config } from "~/config"
const plans = [
{ id: "20", amount: 20, multiplier: null },
{ id: "100", amount: 100, multiplier: "6x more usage than Black 20" },
{ id: "200", amount: 200, multiplier: "21x more usage than Black 20" },
] as const
function PlanIcon(props: { plan: string }) {
return (
<Switch>
<Match when={props.plan === "20"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="1.5" />
</svg>
</Match>
<Match when={props.plan === "100"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="1.5" />
</svg>
</Match>
<Match when={props.plan === "200"}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="2" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="2" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="10" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="2" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="10" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
<rect x="18" y="18" width="4" height="4" rx="0.5" stroke="currentColor" stroke-width="1" />
</svg>
</Match>
</Switch>
)
}
export default function Black() {
const [params] = useSearchParams()
const [selected, setSelected] = createSignal<string | null>(params.plan as string | null)
const selectedPlan = createMemo(() => plans.find((p) => p.id === selected()))
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
@@ -59,6 +16,9 @@ export default function Black() {
: config.github.starsFormatted.compact,
)
// TODO: Frank, toggle this based on availability
const available = false
return (
<div data-page="black">
<Title>opencode</Title>
@@ -188,65 +148,17 @@ export default function Black() {
<p data-slot="subheading">Including Claude, GPT, Gemini, and more</p>
</div>
<Switch>
<Match when={!selected()}>
<div data-slot="pricing">
<For each={plans}>
{(plan) => (
<button type="button" onClick={() => setSelected(plan.id)} data-slot="pricing-card">
<div data-slot="icon">
<PlanIcon plan={plan.id} />
</div>
<p data-slot="price">
<span data-slot="amount">${plan.amount}</span> <span data-slot="period">per month</span>
<Show when={plan.multiplier}>
<span data-slot="multiplier">{plan.multiplier}</span>
</Show>
</p>
</button>
)}
</For>
</div>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
<Match when={available}>
<a href="/black/subscribe" data-slot="button">
Subscribe $200/mo
</a>
<p data-slot="fine-print">Fair usage limits apply</p>
</Match>
<Match when={selectedPlan()}>
{(plan) => (
<div data-slot="selected-plan">
<div data-slot="selected-card">
<div data-slot="icon">
<PlanIcon plan={plan().id} />
</div>
<p data-slot="price">
<span data-slot="amount">${plan().amount}</span>{" "}
<span data-slot="period">per person billed monthly</span>
<Show when={plan().multiplier}>
<span data-slot="multiplier">{plan().multiplier}</span>
</Show>
</p>
<ul data-slot="terms">
<li>Your subscription will not start immediately</li>
<li>You will be added to the waitlist and activated soon</li>
<li>Your card will be only charged when your subscription is activated</li>
<li>Usage limits apply, heavily automated use may reach limits sooner</li>
<li>Subscriptions for individuals, contact Enterprise for teams</li>
<li>Limits may be adjusted and plans may be discontinued in the future</li>
<li>Cancel your subscription at anytime</li>
</ul>
<div data-slot="actions">
<button type="button" onClick={() => setSelected(null)} data-slot="cancel">
Cancel
</button>
<a href={`/black/subscribe?plan=${plan().id}`} data-slot="continue">
Continue
</a>
</div>
</div>
<p data-slot="fine-print">
Prices shown don't include applicable tax · <A href="/legal/terms-of-service">Terms of Service</A>
</p>
</div>
)}
<Match when={!available}>
<p data-slot="back-soon">We’ll be back soon with more availability.</p>
<a data-slot="follow-us" href="https://x.com/opencode" target="_blank">
Follow @opencode
</a>
</Match>
</Switch>
</section>
@@ -24,7 +24,7 @@ export async function GET({ params: { platform } }: APIEvent) {
const resp = await fetch(`https://github.com/anomalyco/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 5,
cacheTtl: 60 * 60 * 24,
cacheEverything: true,
},
} as any)
@@ -1,6 +1,6 @@
import { action } from "@solidjs/router"
import { getRequestEvent } from "solid-js/web"
import { useAuthSession } from "~/context/auth"
import { useAuthSession } from "~/context/auth.session"
import { Dropdown } from "~/component/dropdown"
import "./user-menu.css"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.17",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
-4
View File
@@ -130,10 +130,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.17",
"version": "1.1.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-4
View File
@@ -130,10 +130,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.17",
"version": "1.1.14",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
-4
View File
@@ -130,10 +130,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.17",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
await $`cd ../opencode && bun run build --single`
+4 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
const sidecarConfig = getCurrentSidecar()
@@ -10,4 +10,6 @@ const dir = "src-tauri/target/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
await copyBinaryToSidecarFolder(
`${dir}/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`,
)
+1 -6
View File
@@ -41,13 +41,8 @@ export function getCurrentSidecar(target = RUST_TARGET) {
export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) {
await $`mkdir -p src-tauri/sidecars`
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}`
await $`cp ${source} ${dest}`
console.log(`Copied ${source} to ${dest}`)
}
export function windowsify(path: string) {
if (path.endsWith(".exe")) return path
return `${path}${process.platform === "win32" ? ".exe" : ""}`
}
+3 -5
View File
@@ -2814,9 +2814,7 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"uuid",
"webkit2gtk",
"windows",
]
[[package]]
@@ -5366,13 +5364,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.19.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [
"getrandom 0.3.4",
"js-sys",
"serde_core",
"serde",
"wasm-bindgen",
]
-9
View File
@@ -39,16 +39,7 @@ tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_JobObjects",
"Win32_System_Threading",
"Win32_Security"
] }
-1
View File
@@ -155,7 +155,6 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace())
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
@@ -1,145 +0,0 @@
//! Windows Job Object for reliable child process cleanup.
//!
//! This module provides a wrapper around Windows Job Objects with the
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
//! is closed (including when the parent process exits or crashes), Windows
//! automatically terminates all processes assigned to the job.
//!
//! This is more reliable than manual cleanup because it works even if:
//! - The parent process crashes
//! - The parent is killed via Task Manager
//! - The RunEvent::Exit handler fails to run
use std::io::{Error, Result};
#[cfg(windows)]
use std::sync::Mutex;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A Windows Job Object configured to kill all assigned processes when closed.
///
/// When this struct is dropped or when the owning process exits (even abnormally),
/// Windows will automatically terminate all processes that have been assigned to it.
pub struct JobObject(HANDLE);
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
// can be safely accessed from multiple threads.
unsafe impl Send for JobObject {}
unsafe impl Sync for JobObject {}
impl JobObject {
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
///
/// When the last handle to this job is closed (including on process exit),
/// Windows will terminate all processes assigned to the job.
pub fn new() -> Result<Self> {
unsafe {
// Create an anonymous job object
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
// Configure the job to kill all processes when the handle is closed
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const std::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.map_err(|e| Error::other(e.message()))?;
Ok(Self(job))
}
}
/// Assigns a process to this job object by its process ID.
///
/// Once assigned, the process will be terminated when this job object is dropped
/// or when the owning process exits.
///
/// # Arguments
/// * `pid` - The process ID of the process to assign
pub fn assign_pid(&self, pid: u32) -> Result<()> {
unsafe {
// Open a handle to the process with the minimum required permissions
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
.map_err(|e| Error::other(e.message()))?;
// Assign the process to the job
let result = AssignProcessToJobObject(self.0, process);
// Close our handle to the process - the job object maintains its own reference
let _ = CloseHandle(process);
result.map_err(|e| Error::other(e.message()))
}
}
}
impl Drop for JobObject {
fn drop(&mut self) {
unsafe {
// When this handle is closed and it's the last handle to the job,
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
let _ = CloseHandle(self.0);
}
}
}
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
/// On Windows, when the job object handle is closed (including on crash), all assigned
/// processes are automatically terminated by the OS.
#[cfg(windows)]
pub struct JobObjectState {
job: Mutex<Option<JobObject>>,
error: Mutex<Option<String>>,
}
#[cfg(windows)]
impl JobObjectState {
pub fn new() -> Self {
match JobObject::new() {
Ok(job) => Self {
job: Mutex::new(Some(job)),
error: Mutex::new(None),
},
Err(e) => {
eprintln!("Failed to create job object: {e}");
Self {
job: Mutex::new(None),
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
}
}
}
}
pub fn assign_pid(&self, pid: u32) {
if let Some(job) = self.job.lock().unwrap().as_ref() {
if let Err(e) = job.assign_pid(pid) {
eprintln!("Failed to assign process {pid} to job object: {e}");
*self.error.lock().unwrap() =
Some(format!("Failed to assign process to job object: {e}"));
} else {
println!("Assigned process {pid} to job object for automatic cleanup");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_object_creation() {
let job = JobObject::new();
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
}
}
+22 -77
View File
@@ -1,13 +1,8 @@
mod cli;
#[cfg(windows)]
mod job_object;
mod window_customizer;
use cli::{install_cli, sync_cli};
use futures::FutureExt;
use futures::future;
#[cfg(windows)]
use job_object::*;
use std::{
collections::VecDeque,
net::TcpListener,
@@ -18,29 +13,22 @@ use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, Webvie
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot;
use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone, serde::Serialize)]
struct ServerReadyData {
url: String,
password: Option<String>,
}
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
status: oneshot::Receiver<Result<ServerReadyData, String>>,
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
@@ -92,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
state
.status
.clone()
@@ -149,14 +137,13 @@ fn get_sidecar_port() -> u32 {
}) as u32
}
fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
println!("spawning sidecar on port {port}");
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
.env("OPENCODE_SERVER_PASSWORD", password)
.spawn()
.expect("Failed to spawn opencode");
@@ -197,8 +184,8 @@ fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
child
}
async fn check_server_health(url: &str, password: Option<&str>) -> bool {
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
async fn check_server_health(url: &str) -> bool {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build();
@@ -207,13 +194,9 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
return false;
};
let mut req = client.get(&health_url);
if let Some(password) = password {
req = req.basic_auth("opencode", Some(password));
}
req.send()
client
.get(&health_url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
@@ -223,11 +206,6 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("killall")
.arg("opencode-cli")
.output();
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus existing window when another instance is launched
@@ -260,9 +238,6 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
#[cfg(windows)]
app.manage(JobObjectState::new());
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
@@ -292,7 +267,7 @@ pub fn run() {
window_builder.build().expect("Failed to create window");
let (tx, rx) = oneshot::channel();
let (tx, rx) = tokio::sync::oneshot::channel();
app.manage(ServerState::new(None, rx));
{
@@ -315,14 +290,7 @@ pub fn run() {
let res = match setup_server_connection(&app, custom_url).await {
Ok((child, url)) => {
#[cfg(windows)]
if let Some(child) = &child {
let job_state = app.state::<JobObjectState>();
job_state.assign_pid(child.pid());
}
app.state::<ServerState>().set_child(child);
Ok(url)
}
Err(e) => Err(e),
@@ -376,18 +344,12 @@ fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
async fn setup_server_connection(
app: &AppHandle,
custom_url: Option<String>,
) -> Result<(Option<CommandChild>, ServerReadyData), String> {
) -> Result<(Option<CommandChild>, String), String> {
if let Some(url) = custom_url {
loop {
if check_server_health(&url, None).await {
if check_server_health(&url).await {
println!("Connected to custom server: {}", url);
return Ok((
None,
ServerReadyData {
url: url.clone(),
password: None,
},
));
return Ok((None, url.clone()));
}
const RETRY: &str = "Retry";
@@ -412,41 +374,24 @@ async fn setup_server_connection(
let local_port = get_sidecar_port();
let local_url = format!("http://127.0.0.1:{local_port}");
if !check_server_health(&local_url, None).await {
let password = uuid::Uuid::new_v4().to_string();
match spawn_local_server(app, local_port, &password).await {
Ok(child) => Ok((
Some(child),
ServerReadyData {
url: local_url,
password: Some(password),
},
)),
if !check_server_health(&local_url).await {
match spawn_local_server(app, local_port).await {
Ok(child) => Ok(Some(child)),
Err(err) => Err(err),
}
} else {
Ok((
None,
ServerReadyData {
url: local_url,
password: None,
},
))
Ok(None)
}
.map(|child| (child, local_url))
}
async fn spawn_local_server(
app: &AppHandle,
port: u32,
password: &str,
) -> Result<CommandChild, String> {
let child = spawn_sidecar(app, port, password);
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
let child = spawn_sidecar(app, port);
let url = format!("http://127.0.0.1:{port}");
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(30) {
if timestamp.elapsed() > Duration::from_secs(7) {
break Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
@@ -455,7 +400,7 @@ async fn spawn_local_server(
tokio::time::sleep(Duration::from_millis(10)).await;
if check_server_health(&url, Some(password)).await {
if check_server_health(&url).await {
println!("Server ready after {:?}", timestamp.elapsed());
break Ok(child);
}
+13 -43
View File
@@ -13,11 +13,12 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
import { Accessor, JSX, createResource } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import pkg from "../package.json"
import { Show } from "solid-js"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -28,7 +29,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
let update: Update | null = null
const createPlatform = (password: Accessor<string | null>): Platform => ({
const platform: Platform = {
platform: "desktop",
version: pkg.version,
@@ -255,25 +256,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
},
// @ts-expect-error
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
fetch: tauriFetch,
getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
@@ -283,7 +266,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
},
})
}
createMenu()
@@ -293,39 +276,26 @@ root?.addEventListener("mousewheel", (e) => {
})
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
return (
<PlatformProvider value={platform}>
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<AppBaseProviders>
{ostype() === "macos" && (
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
)}
<ServerGate>
{(data) => {
setServerPassword(data().password)
window.__OPENCODE__ ??= {}
window.__OPENCODE__.serverPassword = data().password ?? undefined
return <AppInterface defaultUrl={data().url} />
}}
</ServerGate>
<ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
</AppBaseProviders>
</PlatformProvider>
)
}, root!)
type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource<ServerReadyData>(() => invoke("ensure_server_ready"))
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
when={serverData.state !== "pending" && serverData()}
when={serverUrl.state !== "pending" && serverUrl()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
@@ -333,7 +303,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
</div>
}
>
{(data) => props.children(data)}
{(serverUrl) => props.children(serverUrl)}
</Show>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.17",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
-4
View File
@@ -130,10 +130,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.17"
version = "1.1.14"
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.1.17/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.1.17/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/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.1.17/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.14/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.17",
"version": "1.1.14",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
-4
View File
@@ -130,10 +130,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.17",
"version": "1.1.14",
"name": "opencode",
"type": "module",
"license": "MIT",
+2 -5
View File
@@ -106,11 +106,8 @@ async function main() {
return
}
// On non-Windows platforms, just verify the binary package exists
// Don't replace the wrapper script - it handles binary execution
const { binaryPath } = findBinary()
console.log(`Platform binary verified at: ${binaryPath}`)
console.log("Wrapper script will handle binary execution")
const { binaryPath, binaryName } = findBinary()
symlinkBinary(binaryPath, binaryName)
} catch (error) {
console.error("Failed to setup opencode binary:", error.message)
process.exit(1)
+6 -10
View File
@@ -50,14 +50,13 @@ export namespace Agent {
external_directory: {
"*": "ask",
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
question: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env": "deny",
"*.env.*": "deny",
"*.env.example": "allow",
},
})
@@ -125,7 +124,6 @@ export namespace Agent {
read: "allow",
external_directory: {
[Truncate.DIR]: "allow",
[Truncate.GLOB]: "allow",
},
}),
user,
@@ -215,16 +213,14 @@ export namespace Agent {
// Ensure Truncate.DIR is allowed unless explicitly configured
for (const name in result) {
const agent = result[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB
})
const explicit = agent.permission.some(
(r) => r.permission === "external_directory" && r.pattern === Truncate.DIR && r.action === "deny",
)
if (explicit) continue
result[name].permission = PermissionNext.merge(
result[name].permission,
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }),
PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow" } }),
)
}
+6 -143
View File
@@ -1,14 +1,6 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2"
import { Identifier } from "../../../id/id"
import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission/next"
import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -16,20 +8,11 @@ export const AgentCommand = cmd({
command: "agent <name>",
describe: "show agent configuration details",
builder: (yargs) =>
yargs
.positional("name", {
type: "string",
demandOption: true,
description: "Agent name",
})
.option("tool", {
type: "string",
description: "Tool id to execute",
})
.option("params", {
type: "string",
description: "Tool params as JSON or a JS object literal",
}),
yargs.positional("name", {
type: "string",
demandOption: true,
description: "Agent name",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const agentName = args.name as string
@@ -40,127 +23,7 @@ export const AgentCommand = cmd({
)
process.exit(1)
}
const availableTools = await getAvailableTools(agent)
const resolvedTools = await resolveTools(agent, availableTools)
const toolID = args.tool as string | undefined
if (toolID) {
const tool = availableTools.find((item) => item.id === toolID)
if (!tool) {
process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL)
process.exit(1)
}
if (resolvedTools[toolID] === false) {
process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL)
process.exit(1)
}
const params = parseToolParams(args.params as string | undefined)
const ctx = await createToolContext(agent)
const result = await tool.execute(params, ctx)
process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL)
return
}
const output = {
...agent,
tools: resolvedTools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
})
},
})
async function getAvailableTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
return ToolRegistry.tools(providerID, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
const disabled = PermissionNext.disabled(
availableTools.map((tool) => tool.id),
agent.permission,
)
const resolved: Record<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = !disabled.has(tool.id)
}
return resolved
}
function parseToolParams(input?: string) {
if (!input) return {}
const trimmed = input.trim()
if (trimmed.length === 0) return {}
const parsed = iife(() => {
try {
return JSON.parse(trimmed)
} catch (jsonError) {
try {
return new Function(`return (${trimmed})`)()
} catch (evalError) {
throw new Error(
`Failed to parse --params. Use JSON or a JS object literal. JSON error: ${jsonError}. Eval error: ${evalError}.`,
)
}
}
})
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Tool params must be an object.")
}
return parsed as Record<string, unknown>
}
async function createToolContext(agent: Agent.Info) {
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
const messageID = Identifier.ascending("message")
const model = agent.model ?? (await Provider.defaultModel())
const now = Date.now()
const message: MessageV2.Assistant = {
id: messageID,
sessionID: session.id,
role: "assistant",
time: {
created: now,
},
parentID: messageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "debug",
agent: agent.name,
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
}
await Session.updateMessage(message)
const ruleset = PermissionNext.merge(agent.permission, session.permission ?? [])
return {
sessionID: session.id,
messageID,
callID: Identifier.ascending("part"),
agent: agent.name,
abort: new AbortController().signal,
metadata: () => {},
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new PermissionNext.DeniedError(ruleset)
}
}
},
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ const FileTreeCommand = cmd({
}),
async handler(args) {
const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
console.log(JSON.stringify(files, null, 2))
console.log(files)
},
})
+4 -6
View File
@@ -394,8 +394,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run opencode
uses: anomalyco/opencode/github@latest${envStr}
@@ -626,7 +624,7 @@ export const GithubRunCommand = cmd({
}
} catch (e: any) {
exitCode = 1
console.error(e instanceof Error ? e.message : String(e))
console.error(e)
let msg = e
if (e instanceof $.ShellError) {
msg = e.stderr.toString()
@@ -917,7 +915,7 @@ export const GithubRunCommand = cmd({
// result should always be assistant just satisfying type checker
if (result.info.role === "assistant" && result.info.error) {
console.error("Agent error:", result.info.error)
console.error(result.info)
throw new Error(
`${result.info.error.name}: ${"message" in result.info.error ? result.info.error.message : ""}`,
)
@@ -946,7 +944,7 @@ export const GithubRunCommand = cmd({
})
if (summary.info.role === "assistant" && summary.info.error) {
console.error("Summary agent error:", summary.info.error)
console.error(summary.info)
throw new Error(
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
)
@@ -964,7 +962,7 @@ export const GithubRunCommand = cmd({
try {
return await core.getIDToken("opencode-github-action")
} catch (error) {
console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error)
console.error("Failed to get OIDC token:", error)
throw new Error(
"Could not fetch an OIDC token. Make sure to add `id-token: write` to your workflow permissions.",
)
+107 -178
View File
@@ -1,6 +1,7 @@
import { cmd } from "./cmd"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
@@ -12,7 +13,6 @@ import { Instance } from "../../project/instance"
import { Installation } from "../../installation"
import path from "path"
import { Global } from "../../global"
import { modify, applyEdits } from "jsonc-parser"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
@@ -366,204 +366,133 @@ export const McpLogoutCommand = cmd({
},
})
async function resolveConfigPath(baseDir: string, global = false) {
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
if (!global) {
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
}
for (const candidate of candidates) {
if (await Bun.file(candidate).exists()) {
return candidate
}
}
// Default to opencode.json if none exist
return candidates[0]
}
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) {
const file = Bun.file(configPath)
let text = "{}"
if (await file.exists()) {
text = await file.text()
}
// Use jsonc-parser to modify while preserving comments
const edits = modify(text, ["mcp", name], mcpConfig, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
const result = applyEdits(text, edits)
await Bun.write(configPath, result)
return configPath
}
export const McpAddCommand = cmd({
command: "add",
describe: "add an MCP server",
async handler() {
await Instance.provide({
directory: process.cwd(),
async fn() {
UI.empty()
prompts.intro("Add MCP server")
UI.empty()
prompts.intro("Add MCP server")
const project = Instance.project
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(Instance.worktree),
resolveConfigPath(Global.Path.config, true),
])
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`)
prompts.outro("MCP server added successfully")
return
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
const mcpConfig: Config.Mcp = {
type: "local",
command: command.split(" "),
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let mcpConfig: Config.Mcp
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
} else {
mcpConfig = {
type: "remote",
url,
}
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`)
prompts.log.info("Add this to your opencode.json:")
prompts.log.info(`
"mcp": {
"${name}": {
"type": "remote",
"url": "${url}",
"oauth": {
"clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""}
}
}
}`)
} else {
prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`)
prompts.log.info("Add this to your opencode.json:")
prompts.log.info(`
"mcp": {
"${name}": {
"type": "remote",
"url": "${url}",
"oauth": {}
}
}`)
}
} else {
const client = new Client({
name: "opencode",
version: "1.0.0",
})
const transport = new StreamableHTTPClientTransport(new URL(url))
await client.connect(transport)
prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`)
}
}
prompts.outro("MCP server added successfully")
},
})
prompts.outro("MCP server added successfully")
},
})
+5 -5
View File
@@ -339,15 +339,13 @@ export const RunCommand = cmd({
}
await bootstrap(process.cwd(), async () => {
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init)
return Server.App().fetch(request)
}) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" })
const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` })
if (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
server.stop()
UI.error(`Command "${args.command}" not found`)
process.exit(1)
}
@@ -372,6 +370,7 @@ export const RunCommand = cmd({
})()
if (!sessionID) {
server.stop()
UI.error("Session not found")
process.exit(1)
}
@@ -390,6 +389,7 @@ export const RunCommand = cmd({
}
await execute(sdk, sessionID)
server.stop()
})
},
})
-4
View File
@@ -1,16 +1,12 @@
import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
@@ -225,7 +225,7 @@ function ApiMethod(props: ApiMethodProps) {
}
onConfirm={async (value) => {
if (!value) return
await sdk.client.auth.set({
sdk.client.auth.set({
providerID: props.providerID,
auth: {
type: "api",
@@ -563,27 +563,25 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
sdk.client.session
.prompt({
sessionID,
...selectedModel,
messageID,
agent: local.agent.current().name,
model: selectedModel,
variant,
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
})
.catch(() => {})
sdk.client.session.prompt({
sessionID,
...selectedModel,
messageID,
agent: local.agent.current().name,
model: selectedModel,
variant,
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
})
}
history.append({
...store.prompt,
@@ -94,7 +94,6 @@ export function Home() {
return (
<>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
<box height={3} />
<Logo />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
<Prompt
@@ -105,11 +104,11 @@ export function Home() {
hint={Hint}
/>
</box>
<box height={3} width="100%" maxWidth={75} alignItems="center" paddingTop={2}>
<Show when={showTips()}>
<Show when={showTips()}>
<box width="100%" maxWidth={75} paddingTop={2} alignItems="center">
<Tips />
</Show>
</box>
</box>
</Show>
<Toast />
</box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
@@ -1,6 +1,6 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
@@ -11,7 +11,6 @@ import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
type PermissionStage = "permission" | "always" | "reject"
@@ -33,9 +32,7 @@ function filetype(input?: string) {
}
function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const { theme, syntax } = useTheme()
const sync = useSync()
const dimensions = useTerminalDimensions()
@@ -57,7 +54,7 @@ function EditBody(props: { request: PermissionRequest }) {
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<scrollbox height="100%">
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
<diff
diff={diff()}
view={view()}
@@ -77,7 +74,7 @@ function EditBody(props: { request: PermissionRequest }) {
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</scrollbox>
</box>
</Show>
</box>
)
@@ -175,95 +172,86 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
message: message || undefined,
})
}}
onCancel={() => {
setStore("stage", "permission")
}}
onCancel={() => setStore("stage", "permission")}
/>
</Match>
<Match when={store.stage === "permission"}>
{(() => {
const body = (
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="←" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
return
}
if (option === "reject") {
if (session()?.parentID) {
setStore("stage", "reject")
return
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
escapeKey="reject"
fullscreen
onSelect={(option) => {
if (option === "always") {
setStore("stage", "always")
return
}
if (option === "reject") {
if (session()?.parentID) {
setStore("stage", "reject")
return
}
sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
})
}
sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
}}
/>
)
return body
})()}
sdk.client.permission.reply({
reply: "reject",
requestID: props.request.id,
})
}
sdk.client.permission.reply({
reply: "once",
requestID: props.request.id,
})
}}
/>
</Match>
</Switch>
)
@@ -339,18 +327,14 @@ function Prompt<const T extends Record<string, string>>(props: {
body: JSX.Element
options: T
escapeKey?: keyof T
fullscreen?: boolean
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keybind = useKeybind()
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
expanded: false,
})
const diffKey = Keybind.parse("ctrl+f")[0]
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
@@ -376,36 +360,17 @@ function Prompt<const T extends Record<string, string>>(props: {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
evt.preventDefault()
evt.stopPropagation()
setStore("expanded", (v) => !v)
}
})
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
const renderer = useRenderer()
const content = () => (
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
: {
top: 0,
maxHeight: 15,
bottom: 0,
left: 0,
right: 0,
position: "relative",
})}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1} flexGrow={1}>
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
@@ -438,11 +403,6 @@ function Prompt<const T extends Record<string, string>>(props: {
</For>
</box>
<box flexDirection="row" gap={2}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
@@ -453,10 +413,4 @@ function Prompt<const T extends Record<string, string>>(props: {
</box>
</box>
)
return (
<Show when={!store.expanded} fallback={<Portal>{content()}</Portal>}>
{content()}
</Show>
)
}
@@ -121,9 +121,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const dialog = useDialog()
useKeyboard((evt) => {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return
// When editing "Other" textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
@@ -0,0 +1,48 @@
import { cmd } from "@/cli/cmd/cmd"
import { Instance } from "@/project/instance"
import path from "path"
import { Server } from "@/server/server"
import { upgrade } from "@/cli/upgrade"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
export const TuiSpawnCommand = cmd({
command: "spawn [project]",
builder: (yargs) =>
withNetworkOptions(yargs).positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
upgrade()
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const bin = process.execPath
const cmd = []
let cwd = process.cwd()
if (bin.endsWith("bun")) {
cmd.push(
process.execPath,
"run",
"--conditions",
"browser",
new URL("../../../index.ts", import.meta.url).pathname,
)
cwd = new URL("../../../../", import.meta.url).pathname
} else cmd.push(process.execPath)
cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd())
const proc = Bun.spawn({
cmd,
cwd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
BUN_OPTIONS: "",
},
})
await proc.exited
await Instance.disposeAll()
await server.stop(true)
},
})
@@ -158,8 +158,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (evt.name === "return") {
const option = selected()
if (option) {
evt.preventDefault()
evt.stopPropagation()
// evt.preventDefault()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
-4
View File
@@ -2,7 +2,6 @@ import { Server } from "../../server/server"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import open from "open"
import { networkInterfaces } from "os"
@@ -33,9 +32,6 @@ export const WebCommand = cmd({
builder: (yargs) => withNetworkOptions(yargs),
describe: "start opencode server and open web interface",
handler: async (args) => {
if (!Flag.OPENCODE_SERVER_PASSWORD) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
UI.empty()
+1 -1
View File
@@ -1159,7 +1159,7 @@ export namespace Config {
if (parsed.success) {
if (!parsed.data.$schema) {
parsed.data.$schema = "https://opencode.ai/config.json"
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {})
await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2))
}
const data = parsed.data
if (data.plugin) {
-2
View File
@@ -20,8 +20,6 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
// Experimental
export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+1 -1
View File
@@ -96,7 +96,7 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
command: [BunProc.which(), "x", "@biomejs/biome", "format", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
+3 -1
View File
@@ -21,6 +21,7 @@ import { ExportCommand } from "./cli/cmd/export"
import { ImportCommand } from "./cli/cmd/import"
import { AttachCommand } from "./cli/cmd/tui/attach"
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
import { AcpCommand } from "./cli/cmd/acp"
import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
@@ -80,6 +81,7 @@ const cli = yargs(hideBin(process.argv))
.command(AcpCommand)
.command(McpCommand)
.command(TuiThreadCommand)
.command(TuiSpawnCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)
@@ -147,7 +149,7 @@ try {
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
console.error(e instanceof Error ? e.message : String(e))
console.error(e)
}
process.exitCode = 1
} finally {
+1 -1
View File
@@ -138,7 +138,7 @@ export namespace Installation {
break
case "brew": {
const formula = await getBrewFormula()
cmd = $`brew upgrade ${formula}`.env({
cmd = $`brew install ${formula}`.env({
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
})
+2 -18
View File
@@ -237,7 +237,6 @@ export namespace Provider {
"nova-lite",
"nova-pro",
"nova-premier",
"nova-2",
"claude",
"deepseek",
].some((m) => modelID.includes(m))
@@ -419,26 +418,11 @@ export namespace Provider {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
// Custom fetch to handle parameter transformation and auth
// Custom fetch to strip Authorization header - AI Gateway uses cf-aig-authorization instead
// Sending Authorization header with invalid value causes auth errors
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
// Strip Authorization header - AI Gateway uses cf-aig-authorization instead
headers.delete("Authorization")
// Transform max_tokens to max_completion_tokens for newer models
if (init?.body && init.method === "POST") {
try {
const body = JSON.parse(init.body as string)
if (body.max_tokens !== undefined && !body.max_completion_tokens) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch (e) {
// If body parsing fails, continue with original request
}
}
return fetch(input, { ...init, headers })
},
},
@@ -364,25 +364,6 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) {
return {
high: {
reasoningConfig: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
reasoningConfig: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
// For Amazon Nova models, use reasoningConfig with maxReasoningEffort
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [
effort,
@@ -476,13 +457,6 @@ export namespace ProviderTransform {
result["chat_template_args"] = { enable_thinking: true }
}
if (["zai", "zhipuai"].includes(model.providerID) && model.api.npm === "@ai-sdk/openai-compatible") {
result["thinking"] = {
type: "enabled",
clear_thinking: false,
}
}
if (model.providerID === "openai" || providerOptions?.setCacheKey) {
result["promptCacheKey"] = sessionID
}
-10
View File
@@ -7,7 +7,6 @@ import { Hono } from "hono"
import { cors } from "hono/cors"
import { stream, streamSSE } from "hono/streaming"
import { proxy } from "hono/proxy"
import { basicAuth } from "hono/basic-auth"
import { Session } from "../session"
import z from "zod"
import { Provider } from "../provider/provider"
@@ -26,7 +25,6 @@ import { Project } from "../project/project"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Command } from "../command"
import { ProviderAuth } from "../provider/auth"
import { Global } from "../global"
@@ -47,7 +45,6 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
@@ -91,18 +88,11 @@ export namespace Server {
else status = 500
return c.json(err.toObject(), { status })
}
if (err instanceof HTTPException) return err.getResponse()
const message = err instanceof Error && err.stack ? err.stack : err.toString()
return c.json(new NamedError.Unknown({ message }).toObject(), {
status: 500,
})
})
.use((c, next) => {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return basicAuth({ username, password })(c, next)
})
.use(async (c, next) => {
const skipLogging = c.req.path === "/log"
if (!skipLogging) {
@@ -664,7 +664,6 @@ export namespace MessageV2 {
return `${msg}: ${e.responseBody}`
}).trim()
const metadata = e.url ? { url: e.url } : undefined
return new MessageV2.APIError(
{
message,
@@ -672,7 +671,6 @@ export namespace MessageV2 {
isRetryable: e.isRetryable,
responseHeaders: e.responseHeaders,
responseBody: e.responseBody,
metadata,
},
{ cause: e },
).toObject()
+2 -2
View File
@@ -119,7 +119,7 @@ export const EditTool = Tool.define("edit", {
},
})
let output = "Edit applied successfully."
let output = ""
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
@@ -129,7 +129,7 @@ export const EditTool = Tool.define("edit", {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
}
return {
+6 -13
View File
@@ -56,9 +56,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
const session = await iife(async () => {
if (params.session_id) {
const found = await Session.get(params.session_id).catch(() => {})
@@ -79,15 +76,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
pattern: "*",
action: "deny",
},
...(hasTaskPermission
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
{
permission: "task",
pattern: "*",
action: "deny",
},
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
@@ -153,7 +146,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
task: false,
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
-1
View File
@@ -10,7 +10,6 @@ export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const DIR = path.join(Global.Path.data, "tool-output")
export const GLOB = path.join(DIR, "*")
const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+3 -3
View File
@@ -47,7 +47,7 @@ export const WriteTool = Tool.define("write", {
})
FileTime.read(ctx.sessionID, filepath)
let output = "Wrote file successfully."
let output = ""
await LSP.touchFile(filepath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilepath = Filesystem.normalizePath(filepath)
@@ -59,12 +59,12 @@ export const WriteTool = Tool.define("write", {
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
if (file === normalizedFilepath) {
output += `\n\nLSP errors detected in this file:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
continue
}
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
projectDiagnosticsCount++
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
output += `\n<project_diagnostics>\n${file}\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</project_diagnostics>\n`
}
return {
@@ -461,7 +461,6 @@ test("Truncate.DIR is allowed even when user denies external_directory globally"
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
@@ -485,7 +484,6 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
@@ -508,7 +506,6 @@ test("explicit Truncate.DIR deny is respected", async () => {
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
},
})
})
+8 -8
View File
@@ -124,7 +124,7 @@ describe("tool.read external_directory permission", () => {
})
})
describe("tool.read env file permissions", () => {
describe("tool.read env file blocking", () => {
const cases: [string, boolean][] = [
[".env", true],
[".env.local", true],
@@ -136,7 +136,7 @@ describe("tool.read env file permissions", () => {
]
describe.each(["build", "plan"])("agent=%s", (agentName) => {
test.each(cases)("%s asks=%s", async (filename, shouldAsk) => {
test.each(cases)("%s blocked=%s", async (filename, blocked) => {
await using tmp = await tmpdir({
init: (dir) => Bun.write(path.join(dir, filename), "content"),
})
@@ -144,15 +144,11 @@ describe("tool.read env file permissions", () => {
directory: tmp.path,
fn: async () => {
const agent = await Agent.get(agentName)
let askedForEnv = false
const ctxWithPermissions = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true
}
if (rule.action === "deny") {
throw new PermissionNext.DeniedError(agent.permission)
}
@@ -160,8 +156,12 @@ describe("tool.read env file permissions", () => {
},
}
const read = await ReadTool.init()
await read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
expect(askedForEnv).toBe(shouldAsk)
const promise = read.execute({ filePath: path.join(tmp.path, filename) }, ctxWithPermissions)
if (blocked) {
await expect(promise).rejects.toThrow(PermissionNext.DeniedError)
} else {
expect((await promise).output).toContain("content")
}
},
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.17",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.17",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.17",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"scripts": {
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.17",
"version": "1.1.14",
"type": "module",
"license": "MIT",
"exports": {
@@ -51,7 +51,6 @@
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"dompurify": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
+2 -1
View File
@@ -123,13 +123,13 @@
&[data-size="normal"] {
height: 24px;
line-height: 24px;
padding: 0 6px;
&[data-icon] {
padding: 0 12px 0 4px;
}
font-size: var(--font-size-small);
line-height: var(--line-height-large);
gap: 6px;
/* text-12-medium */
@@ -137,6 +137,7 @@
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
}
+3 -9
View File
@@ -10,15 +10,9 @@ export interface ListSearchProps {
autofocus?: boolean
}
export interface ListGroup<T> {
category: string
items: T[]
}
export interface ListProps<T> extends FilteredListProps<T> {
class?: string
children: (item: T) => JSX.Element
groupHeader?: (group: ListGroup<T>) => JSX.Element
emptyMessage?: string
onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
onMove?: (item: T | undefined) => void
@@ -122,7 +116,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
setScrollRef,
})
function GroupHeader(groupProps: { category: string; children?: JSX.Element }): JSX.Element {
function GroupHeader(props: { category: string }): JSX.Element {
const [stuck, setStuck] = createSignal(false)
const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
@@ -144,7 +138,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return (
<div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
{groupProps.children ?? groupProps.category}
{props.category}
</div>
)
}
@@ -191,7 +185,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
{(group) => (
<div data-slot="list-group">
<Show when={group.category}>
<GroupHeader category={group.category}>{props.groupHeader?.(group)}</GroupHeader>
<GroupHeader category={group.category} />
</Show>
<div data-slot="list-items">
<For each={group.items}>
+2 -32
View File
@@ -1,8 +1,6 @@
import { useMarked } from "../context/marked"
import DOMPurify from "dompurify"
import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createResource, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
type Entry = {
hash: string
@@ -12,31 +10,6 @@ type Entry = {
const max = 200
const cache = new Map<string, Entry>()
if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return
const rel = node.getAttribute("rel") ?? ""
const set = new Set(rel.split(/\s+/).filter(Boolean))
set.add("noopener")
set.add("noreferrer")
node.setAttribute("rel", Array.from(set).join(" "))
})
}
const config = {
USE_PROFILES: { html: true, mathMl: true },
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: ["style"],
FORBID_CONTENTS: ["style", "script"],
}
function sanitize(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}
function touch(key: string, value: Entry) {
cache.delete(key)
cache.set(key, value)
@@ -61,8 +34,6 @@ export function Markdown(
const [html] = createResource(
() => local.text,
async (markdown) => {
if (isServer) return ""
const hash = checksum(markdown)
const key = local.cacheKey ?? hash
@@ -75,9 +46,8 @@ export function Markdown(
}
const next = await marked.parse(markdown)
const safe = sanitize(next)
if (key && hash) touch(key, { hash, html: safe })
return safe
if (key && hash) touch(key, { hash, html: next })
return next
},
{ initialValue: "" },
)
+25 -25
View File
@@ -28,37 +28,35 @@
min-width: 0;
gap: 28px;
overflow-anchor: none;
}
[data-slot="session-turn-user-badges"] {
position: absolute;
right: 0;
display: flex;
gap: 6px;
padding-left: 16px;
background: linear-gradient(to right, transparent, var(--background-stronger) 12px);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
[data-slot="session-turn-user-badges"] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
&:hover [data-slot="session-turn-user-badges"] {
&[data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
}
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
white-space: nowrap;
color: var(--text-base);
background: var(--surface-raised-base);
}
[data-slot="session-turn-sticky-title"] {
@@ -83,6 +81,8 @@
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
align-self: stretch;
height: 32px;
}
+5 -10
View File
@@ -22,8 +22,6 @@ import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { ProviderIcon } from "./provider-icon"
import type { IconName } from "./provider-icons/types"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"
import { Card } from "./card"
@@ -376,6 +374,7 @@ export function SessionTurn(
diffLimit: diffInit,
status: rawStatus(),
duration: duration(),
userMessageHovered: false,
})
createEffect(
@@ -476,6 +475,8 @@ export function SessionTurn(
data-slot="session-turn-message-container"
class={props.classes?.container}
style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
onMouseEnter={() => setStore("userMessageHovered", true)}
onMouseLeave={() => setStore("userMessageHovered", false)}
>
<Switch>
<Match when={isShellMode()}>
@@ -495,18 +496,12 @@ export function SessionTurn(
</Match>
</Switch>
</div>
<div data-slot="session-turn-user-badges">
<div data-slot="session-turn-user-badges" data-visible={store.userMessageHovered}>
<Show when={(msg() as UserMessage).agent}>
<span data-slot="session-turn-badge">{(msg() as UserMessage).agent}</span>
</Show>
<Show when={(msg() as UserMessage).model?.modelID}>
<span data-slot="session-turn-badge" class="inline-flex items-center gap-1">
<ProviderIcon
id={(msg() as UserMessage).model!.providerID as IconName}
class="size-3.5 shrink-0"
/>
{(msg() as UserMessage).model?.modelID}
</span>
<span data-slot="session-turn-badge">{(msg() as UserMessage).model?.modelID}</span>
</Show>
<span data-slot="session-turn-badge">{(msg() as UserMessage).variant || "default"}</span>
</div>
+1 -6
View File
@@ -24,12 +24,7 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
const [grouped, { refetch }] = createResource(
() => ({
filter: store.filter,
items:
typeof props.items === "function"
? props.items.length === 0
? (props.items as () => T[])()
: undefined
: props.items,
items: typeof props.items === "function" ? undefined : props.items,
}),
async ({ filter, items }) => {
const needle = filter?.toLowerCase()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.17",
"version": "1.1.14",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.17",
"version": "1.1.14",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+22 -24
View File
@@ -358,7 +358,7 @@ Start a headless OpenCode server for API access. Check out the [server docs](/do
opencode serve
```
This starts an HTTP server that provides API access to opencode functionality without the TUI interface. Set `OPENCODE_SERVER_PASSWORD` to enable HTTP basic auth (username defaults to `opencode`).
This starts an HTTP server that provides API access to opencode functionality without the TUI interface.
#### Flags
@@ -454,7 +454,7 @@ Start a headless OpenCode server with a web interface.
opencode web
```
This starts an HTTP server and opens a web browser to access OpenCode through a web interface. Set `OPENCODE_SERVER_PASSWORD` to enable HTTP basic auth (username defaults to `opencode`).
This starts an HTTP server and opens a web browser to access OpenCode through a web interface.
#### Flags
@@ -551,28 +551,26 @@ The opencode CLI takes the following global flags.
OpenCode can be configured using environment variables.
| Variable | Type | Description |
| ------------------------------------- | ------- | ------------------------------------------------- |
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
| `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data |
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates |
| `OPENCODE_PERMISSION` | string | Inlined json permissions config |
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Disable default plugins |
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Disable automatic LSP server downloads |
| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models |
| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Disable automatic context compaction |
| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Disable reading from `.claude` (prompt + skills) |
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
| `OPENCODE_SERVER_PASSWORD` | string | Enable basic auth for `serve`/`web` |
| `OPENCODE_SERVER_USERNAME` | string | Override basic auth username (default `opencode`) |
| Variable | Type | Description |
| ------------------------------------- | ------- | ------------------------------------------------ |
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
| `OPENCODE_DISABLE_PRUNE` | boolean | Disable pruning of old data |
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | Disable automatic terminal title updates |
| `OPENCODE_PERMISSION` | string | Inlined json permissions config |
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | Disable default plugins |
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | Disable automatic LSP server downloads |
| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models |
| `OPENCODE_DISABLE_AUTOCOMPACT` | boolean | Disable automatic context compaction |
| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Disable reading from `.claude` (prompt + skills) |
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
---
+5 -12
View File
@@ -57,13 +57,12 @@ Or you can set it up manually.
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
persist-credentials: false
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run OpenCode
- name: Run OpenCode
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -136,8 +135,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run OpenCode
uses: anomalyco/opencode/github@latest
@@ -175,8 +172,6 @@ jobs:
issues: read
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -228,8 +223,6 @@ jobs:
- uses: actions/checkout@v6
if: steps.check.outputs.result == 'true'
with:
persist-credentials: false
- uses: anomalyco/opencode/github@latest
if: steps.check.outputs.result == 'true'
-10
View File
@@ -33,16 +33,6 @@ opencode serve --cors http://localhost:5173 --cors https://app.example.com
---
### Authentication
Set `OPENCODE_SERVER_PASSWORD` to protect the server with HTTP basic auth. The username defaults to `opencode`, or set `OPENCODE_SERVER_USERNAME` to override it. This applies to both `opencode serve` and `opencode web`.
```bash
OPENCODE_SERVER_PASSWORD=your-password opencode serve
```
---
### How it works
When you run `opencode` it starts a TUI and a server. Where the TUI is the
+2 -2
View File
@@ -128,10 +128,10 @@ Control which skills agents can access using pattern-based permissions in `openc
{
"permission": {
"skill": {
"*": "allow",
"pr-review": "allow",
"internal-*": "deny",
"experimental-*": "ask"
"experimental-*": "ask",
"*": "allow"
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.1.17",
"version": "1.1.14",
"publisher": "sst-dev",
"repository": {
"type": "git",
-4
View File
@@ -156,10 +156,6 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_SESSION_SECRET": {
"type": "sst.sst.Secret"
"value": string
}
"ZenData": {
"name": string
"type": "sst.cloudflare.Bucket"