mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 23:27:36 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d040bd41 | ||
|
|
5848ee0d24 | ||
|
|
839aa25c3e | ||
|
|
c42f1c9232 | ||
|
|
417f6d234d | ||
|
|
45a13af0ee | ||
|
|
e50d845451 | ||
|
|
56816621f8 | ||
|
|
7125f5f8b5 | ||
|
|
01a6ed8d97 | ||
|
|
004583d598 | ||
|
|
cbd911368e | ||
|
|
8bfb247854 |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,11 +201,10 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -215,7 +214,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -251,7 +250,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -278,7 +277,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -295,7 +294,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -319,7 +318,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -339,7 +338,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -407,7 +406,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -456,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -493,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -528,7 +527,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -541,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -555,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -570,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -609,7 +608,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -639,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -654,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -678,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -699,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -721,7 +720,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -756,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -776,7 +775,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -810,7 +809,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -829,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -875,7 +874,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -889,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -924,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -959,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -992,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1033,7 +1032,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
|
||||
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
|
||||
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
|
||||
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
|
||||
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -166,6 +166,45 @@ test.describe("session timeline projection", () => {
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not repeat a review comment file as an attachment", async ({ page }) => {
|
||||
const message = userMessage([
|
||||
userText("what's goin on here", { id: "prt_user_review_comment" }),
|
||||
{
|
||||
id: "prt_user_review_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "LiveRuntime.ts",
|
||||
url: "file:///repo/LiveRuntime.ts?start=14&end=32",
|
||||
},
|
||||
{
|
||||
id: "prt_user_unrelated_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "notes.txt",
|
||||
url: "data:text/plain;base64,bm90ZXM=",
|
||||
},
|
||||
])
|
||||
message.metadata = {
|
||||
displayText: "what's goin on here",
|
||||
comments: [
|
||||
{
|
||||
path: "LiveRuntime.ts",
|
||||
comment: "what's goin on here",
|
||||
selection: { startLine: 14, startChar: 0, endLine: 32, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await setupTimeline(page, { messages: [message, assistantMessage()] })
|
||||
|
||||
const user = page.locator('[data-component="user-message"]')
|
||||
await expect(user.getByText("LiveRuntime.ts:14-32", { exact: true })).toBeVisible()
|
||||
const attachments = user.locator('[data-slot="user-message-attachments"]')
|
||||
await expect(attachments.getByText("LiveRuntime.ts", { exact: true })).toHaveCount(0)
|
||||
await expect(attachments.getByText("notes.txt", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -102,7 +102,6 @@ export function AppInterface(props: {
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
serversPending?: boolean
|
||||
router?: Component<BaseRouterProps>
|
||||
}) {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
@@ -129,7 +128,6 @@ export function AppInterface(props: {
|
||||
defaultServer={props.defaultServer}
|
||||
canonicalLocalServer={props.canonicalLocalServer}
|
||||
servers={props.servers}
|
||||
pending={props.serversPending}
|
||||
>
|
||||
<SettingsProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LocationProvider } from "@/workspaces/location"
|
||||
import { ModelsProvider } from "@/providers/models/models"
|
||||
import { ComposerPersistenceProvider } from "@/composer/persistence"
|
||||
import { ServerProvider, useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import NewSession from "@/new-session/screen"
|
||||
@@ -30,23 +30,11 @@ export function DraftRoute() {
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
|
||||
return (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<Show
|
||||
when={conn()}
|
||||
keyed
|
||||
fallback={
|
||||
// The shell is up before the local service has connected; hold the panel's place.
|
||||
<Show when={servers.pending}>
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised />
|
||||
</SessionRouteFrame>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ResolvedDraftContent draft={props.draft} />
|
||||
|
||||
@@ -199,9 +199,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
defaultServer?: ServerConnection.Key
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
// The host is still discovering connections (desktop: the local service, WSL, SSH). The shell
|
||||
// renders meanwhile; nothing that depends on the list being complete may act on it yet.
|
||||
pending?: boolean
|
||||
}) => {
|
||||
const [store, setStore, _] = persisted(
|
||||
{
|
||||
@@ -261,9 +258,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
get visible() {
|
||||
return visibleServers()
|
||||
},
|
||||
get pending() {
|
||||
return props.pending ?? false
|
||||
},
|
||||
isHidden(key: ServerConnection.Key) {
|
||||
return store.hidden[key] ?? false
|
||||
},
|
||||
|
||||
@@ -62,23 +62,12 @@ export function AppRoutes() {
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
const servers = useServers()
|
||||
const connection = createMemo(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
|
||||
)
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={connection()}
|
||||
keyed
|
||||
fallback={
|
||||
<Show when={servers.pending}>
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={connection()} keyed>
|
||||
{(connection) => <ServerProvider conn={connection}>{props.children}</ServerProvider>}
|
||||
</Show>
|
||||
)
|
||||
@@ -87,7 +76,7 @@ function TargetServerRoute(props: ParentProps) {
|
||||
function AppLayout(props: ParentProps) {
|
||||
const servers = useServers()
|
||||
return (
|
||||
<Show when={servers.list.length > 0 || servers.pending} fallback={<ConnectServerScreen />}>
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<BrowserAttachmentsProvider>
|
||||
|
||||
@@ -138,10 +138,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
onCleanup(memory.dispose)
|
||||
|
||||
// Tabs of a server that is gone are dropped, but not while the host is still discovering its
|
||||
// servers: the shell mounts before the local service has connected.
|
||||
createEffect(() => {
|
||||
if (!ready() || !recentReady() || servers.pending) return
|
||||
if (!ready() || !recentReady()) return
|
||||
const serversSet = new Set(servers.list.map(ServerConnection.key))
|
||||
const next = store.filter((tab) => serversSet.has(tab.server))
|
||||
if (next.length !== store.length) {
|
||||
@@ -167,7 +165,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!closedReady() || servers.pending) return
|
||||
if (!closedReady()) return
|
||||
const serversSet = new Set(servers.list.map(ServerConnection.key))
|
||||
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
|
||||
if (next.length !== closed.length) setClosed(() => next)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode/script"
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
if (Script.channel !== "beta" && Script.channel !== "latest") {
|
||||
throw new Error("Homebrew publishing requires the beta or latest channel")
|
||||
}
|
||||
const valid =
|
||||
Script.channel === "beta"
|
||||
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
|
||||
: /^\d+\.\d+\.\d+$/.test(Script.version)
|
||||
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
|
||||
const outdir = path.join(root, "homebrew-tap")
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
const name = Script.channel === "beta" ? "opencode-beta" : "opencode-v2"
|
||||
const formulaClass = Script.channel === "beta" ? "OpencodeBeta" : "OpencodeV2"
|
||||
|
||||
const targets = await Promise.all(
|
||||
[
|
||||
{ name: "darwin-arm64", archive: "zip" },
|
||||
{ name: "darwin-x64-baseline", archive: "zip" },
|
||||
{ name: "linux-arm64", archive: "tar.gz" },
|
||||
{ name: "linux-x64-baseline", archive: "tar.gz" },
|
||||
].map(async (target) => {
|
||||
const filename = `opencode-${target.name}.${target.archive}`
|
||||
const file = Bun.file(path.join(root, filename))
|
||||
if (!(await file.exists()) || !file.size) throw new Error(`Missing Homebrew archive: ${filename}`)
|
||||
const sha256 = new Bun.CryptoHasher("sha256")
|
||||
for await (const chunk of file.stream()) sha256.update(chunk)
|
||||
return {
|
||||
...target,
|
||||
url: `https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${filename}`,
|
||||
sha256: sha256.digest("hex"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await rm(outdir, { recursive: true, force: true })
|
||||
if (dryRun) await mkdir(outdir, { recursive: true })
|
||||
if (!dryRun) {
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required to update the Homebrew tap")
|
||||
await $`git clone ${`https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`} ${outdir}`
|
||||
await $`git checkout -B master`.cwd(outdir)
|
||||
}
|
||||
|
||||
const target = (name: string) => {
|
||||
const result = targets.find((item) => item.name === name)
|
||||
if (!result) throw new Error(`Missing Homebrew target: ${name}`)
|
||||
return result
|
||||
}
|
||||
const macArm = target("darwin-arm64")
|
||||
const macIntel = target("darwin-x64-baseline")
|
||||
const linuxArm = target("linux-arm64")
|
||||
const linuxIntel = target("linux-x64-baseline")
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, `${name}.rb`),
|
||||
[
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
`class ${formulaClass} < Formula`,
|
||||
` desc "OpenCode V2${Script.channel === "beta" ? " beta" : ""} - the AI coding agent for the terminal"`,
|
||||
' homepage "https://github.com/anomalyco/opencode"',
|
||||
` version "${Script.version}"`,
|
||||
' license "MIT"',
|
||||
"",
|
||||
' depends_on "ripgrep"',
|
||||
' conflicts_with "opencode", because: "both install an opencode binary"',
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${macArm.url}"`,
|
||||
` sha256 "${macArm.sha256}"`,
|
||||
" else",
|
||||
` url "${macIntel.url}"`,
|
||||
` sha256 "${macIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${linuxArm.url}"`,
|
||||
` sha256 "${linuxArm.sha256}"`,
|
||||
" else",
|
||||
` url "${linuxIntel.url}"`,
|
||||
` sha256 "${linuxIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
console.log(`Prepared ${name} ${Script.version} in ${outdir}`)
|
||||
if (dryRun) process.exit(0)
|
||||
|
||||
await $`git add ${name + ".rb"}`.cwd(outdir)
|
||||
if ((await $`git diff --cached --quiet`.cwd(outdir).nothrow()).exitCode !== 0) {
|
||||
await $`git commit -m ${`chore: update ${name} to ${Script.version}`}`.cwd(outdir)
|
||||
await $`git push origin master`.cwd(outdir)
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "homebrew",
|
||||
version: Script.version,
|
||||
metadata: { package: `anomalyco/tap/${name}` },
|
||||
})
|
||||
@@ -149,6 +149,10 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
|
||||
|
||||
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
|
||||
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
|
||||
await $`bun ./script/publish-homebrew.ts ${dryRun ? ["--dry-run"] : []}`.env({
|
||||
...process.env,
|
||||
OPENCODE_CLI_DIST: root,
|
||||
})
|
||||
}
|
||||
|
||||
async function archive(bin: string, target: string, binary: string, directory: string) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "brew"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
@@ -96,6 +96,13 @@ const make = Effect.gen(function* () {
|
||||
process.platform === "win32" ? "opencode.exe" : "opencode",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
if (
|
||||
["opencode-beta", "opencode-v2"].some((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
)
|
||||
return "brew"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
@@ -113,7 +120,7 @@ const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const removal = (method: Method) => {
|
||||
if (method === "curl" || !installedPackage) return undefined
|
||||
if (method === "curl" || method === "brew" || !installedPackage) return undefined
|
||||
const commands = {
|
||||
npm: ["npm", "uninstall", "--global", installedPackage],
|
||||
pnpm: ["pnpm", "remove", "--global", installedPackage],
|
||||
@@ -133,11 +140,12 @@ const make = Effect.gen(function* () {
|
||||
}
|
||||
}
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const release = Effect.fnUntraced(function* (method?: Method) {
|
||||
const distribution = method === "brew" ? "homebrew" : "npm"
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
{
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
@@ -153,7 +161,11 @@ const make = Effect.gen(function* () {
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
const latest = () =>
|
||||
method().pipe(
|
||||
Effect.flatMap(release),
|
||||
Effect.map((data) => data.version),
|
||||
)
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
|
||||
@@ -163,12 +175,12 @@ const make = Effect.gen(function* () {
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName = (yield* release()).package
|
||||
const packageName = (yield* release(method)).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
@@ -202,6 +214,7 @@ const make = Effect.gen(function* () {
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("upgrade command", () => {
|
||||
})
|
||||
|
||||
test("rejects unsupported methods before attempting an upgrade", async () => {
|
||||
const result = await cli(["--method", "brew"])
|
||||
const result = await cli(["--method", "apt"])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.events).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -456,6 +456,10 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
|
||||
|
||||
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
|
||||
|
||||
export type ProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
@@ -1656,19 +1660,19 @@ export type SessionInboxMove = {
|
||||
payload: SessionInboxMovePayload
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
@@ -1898,7 +1902,7 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ProviderSettings
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
@@ -2107,13 +2111,13 @@ export type ConfigEntry =
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ConfigProviderSettings
|
||||
settings?: ConfigModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: ConfigProviderSettings
|
||||
settings?: ConfigModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
|
||||
@@ -15,8 +15,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
TypeScript-only syntax is rejected rather than stripped before execution.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
@@ -41,8 +40,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
|
||||
default in a `for...of` head and Unicode-escaped keywords.
|
||||
|
||||
## Values and literals
|
||||
|
||||
@@ -494,9 +491,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -19,12 +19,6 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -32,8 +26,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports: Record<string, Record<string, string>>
|
||||
imports?: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
@@ -41,12 +41,14 @@ try {
|
||||
]
|
||||
}),
|
||||
)
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
if (pkg.imports) {
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
}
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { parse, type Program } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { PendingThrow } from "./model.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
|
||||
@@ -110,16 +106,7 @@ export const executeProgram = <R>(
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): Program => {
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
return parse(executableCode, {
|
||||
return parse(code, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -15,6 +15,12 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("source syntax", () => {
|
||||
test("rejects TypeScript-only syntax", async () => {
|
||||
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
@@ -101,7 +107,7 @@ describe("host errors escaping built-ins", () => {
|
||||
test("report the location of the call that raised them", async () => {
|
||||
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
|
||||
})
|
||||
|
||||
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
|
||||
@@ -109,13 +115,13 @@ describe("host errors escaping built-ins", () => {
|
||||
})
|
||||
|
||||
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
|
||||
"(line 1, col 14)",
|
||||
"(line 1, col 10)",
|
||||
)
|
||||
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
|
||||
"(line 2, col 5)",
|
||||
"(line 1, col 8)",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -128,7 +134,7 @@ describe("host errors escaping built-ins", () => {
|
||||
|
||||
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
|
||||
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,7 +153,7 @@ describe("call depth", () => {
|
||||
test("uncaught overflow reports the call that overflowed", async () => {
|
||||
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
|
||||
})
|
||||
|
||||
test("the limit is 10000 nested calls", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
|
||||
import { ProviderShared } from "@opencode/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { makeParser } from "effect/unstable/encoding/Sse"
|
||||
import type { ID, Info } from "./model.js"
|
||||
import type { ID, RuntimeInfo } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: Info
|
||||
readonly model: RuntimeInfo
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: Info
|
||||
readonly model: RuntimeInfo
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
function prepareOptions(model: RuntimeInfo, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.canonical ?? model.providerID,
|
||||
@@ -182,8 +182,8 @@ export interface Interface {
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
|
||||
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
|
||||
@@ -302,7 +302,7 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
@@ -399,7 +399,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
function mapBodyToProviderOptions(model: Info, packageName: string) {
|
||||
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
|
||||
const settings = requestSettings(model.settings)
|
||||
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
|
||||
const forceReasoning =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Document } from "@opencode/schema/config"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { ManagedPolicy } from "../../managed-policy.js"
|
||||
import { Wildcard } from "../../util/wildcard.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
@@ -11,16 +12,30 @@ export const Plugin = define({
|
||||
id: "opencode.config.policy",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
|
||||
const policies = () =>
|
||||
loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
// Authored documents reverse so user-global policy outranks repository policy; organization statements
|
||||
// from the connected Console follow every authored one and have the final say.
|
||||
const policies = () => {
|
||||
const organization = managed.current()
|
||||
return [
|
||||
...loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
.map((policy) => ({ ...policy, message: "Blocked by configuration policy" })),
|
||||
...organization.statements.map((policy) => ({
|
||||
...policy,
|
||||
message: organization.organization
|
||||
? `Blocked by ${organization.organization}'s policy`
|
||||
: "Blocked by your organization's policy",
|
||||
})),
|
||||
]
|
||||
}
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const current = policies()
|
||||
for (const record of providers.list()) {
|
||||
const policy = policies().findLast(
|
||||
const policy = current.findLast(
|
||||
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
|
||||
)
|
||||
if (policy?.effect === "deny") providers.remove(record.provider.id)
|
||||
@@ -29,16 +44,17 @@ export const Plugin = define({
|
||||
yield* ctx.permission.hook("evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
const current = policies()
|
||||
const denied = event.resources.some((resource) => {
|
||||
const policy = current.findLast(
|
||||
(policy) =>
|
||||
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
|
||||
const denied = event.resources
|
||||
.map((resource) =>
|
||||
current.findLast(
|
||||
(policy) =>
|
||||
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
|
||||
),
|
||||
)
|
||||
return policy?.effect === "deny"
|
||||
})
|
||||
.find((policy) => policy?.effect === "deny")
|
||||
if (!denied) return
|
||||
event.effect = "deny"
|
||||
event.message = "Blocked by configuration policy"
|
||||
event.message = denied.message
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export * as ManagedPolicy from "./managed-policy.js"
|
||||
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
|
||||
/** Policy statements the connected OpenCode Console compiled for whoever it authenticated. */
|
||||
export interface State {
|
||||
readonly statements: ReadonlyArray<ConfigPolicy.Info>
|
||||
/** Organization name for denial messages, when the connection knows it. */
|
||||
readonly organization?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Synchronous so catalog transforms can consult the statements while they run. */
|
||||
readonly current: () => State
|
||||
/** Replaces the whole state; statements never merge across connections. */
|
||||
readonly set: (state: State) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ManagedPolicy") {}
|
||||
|
||||
const layer = Layer.sync(Service, () => {
|
||||
const state: { current: State } = { current: { statements: [] } }
|
||||
return Service.of({
|
||||
current: () => state.current,
|
||||
set: (next) =>
|
||||
Effect.sync(() => {
|
||||
state.current = next
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -8,6 +8,7 @@ import { AISDK } from "./aisdk.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
|
||||
import type { RuntimeInfo } from "./model.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
@@ -117,9 +118,9 @@ export interface Resolved {
|
||||
readonly cost: Info["cost"]
|
||||
/** Catalog token limits used by Core for context management. */
|
||||
readonly limit: Info["limit"]
|
||||
/** Model policy overrides the provider policy; omitted means local compaction. */
|
||||
/** Model policy overrides the provider policy; omitted means summary compaction. */
|
||||
readonly compaction?: Provider.Compaction
|
||||
/** Model transport overrides the provider transport; omitted means HTTP. */
|
||||
/** Provider transport policy; omitted means HTTP. */
|
||||
readonly transport?: Provider.Transport
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ export const withVariant = (
|
||||
variant
|
||||
? {
|
||||
...model,
|
||||
settings: Provider.mergeOverlay(model.settings, variant.settings),
|
||||
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
|
||||
headers: Provider.mergeHeaders(model.headers, variant.headers),
|
||||
body: Provider.mergeOverlay(model.body, variant.body),
|
||||
}
|
||||
@@ -159,11 +160,11 @@ export const withVariant = (
|
||||
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<
|
||||
@@ -191,7 +192,7 @@ export const fromCatalogModel = (
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
@@ -248,7 +249,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
})
|
||||
})
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return {
|
||||
...model,
|
||||
@@ -260,7 +261,7 @@ function prepareRuntimeModel(model: Info, credential: Credential.Value | undefin
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
@@ -270,7 +271,7 @@ function validateProviderVariables(
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
@@ -280,14 +281,14 @@ function prepareProviderSettings(
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
@@ -310,14 +311,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
const unsupported = (model: RuntimeInfo) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
|
||||
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
|
||||
new ModelInitializationError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
@@ -359,7 +360,11 @@ export const layer = Layer.effect(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const selectedVariant = yield* withVariant(selected, variant)
|
||||
const runtimeInfo: RuntimeInfo = {
|
||||
...selectedVariant,
|
||||
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
|
||||
}
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
@@ -382,7 +387,7 @@ export const layer = Layer.effect(
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: runtimeInfo.settings?.compaction,
|
||||
transport: runtimeInfo.settings?.transport,
|
||||
transport: provider?.settings?.transport,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
@@ -406,7 +411,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
function hasConfiguredAuth(model: RuntimeInfo) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
|
||||
@@ -36,6 +36,9 @@ export type Ref = typeof Ref.Type
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
/** Effective provider and model settings used only while constructing a runtime model. */
|
||||
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
|
||||
|
||||
export type MutableInfo = DeepMutable<Info>
|
||||
|
||||
export { Event } from "@opencode/schema/model"
|
||||
@@ -188,7 +191,10 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
settings: Provider.mergeOverlay(
|
||||
Provider.modelSettings(provider?.settings),
|
||||
Provider.modelSettings(model.settings),
|
||||
),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Integration } from "../integration.js"
|
||||
import { Job } from "../job.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { ManagedPolicy } from "../managed-policy.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -92,6 +93,7 @@ import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { ProviderPlugins } from "./provider.js"
|
||||
import { OpencodePlugin } from "./provider/opencode.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
@@ -126,6 +128,7 @@ const services = [
|
||||
KV.Service,
|
||||
LLMClient.Service,
|
||||
Location.Service,
|
||||
ManagedPolicy.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
Npm.Service,
|
||||
@@ -178,6 +181,7 @@ export const requirements = LayerNode.group([
|
||||
KV.node,
|
||||
llmClient,
|
||||
Location.node,
|
||||
ManagedPolicy.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
Npm.node,
|
||||
@@ -260,6 +264,10 @@ const post = [
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
// Repository config must not switch off policy enforcement or the Console connection that delivers
|
||||
// organization statements, so plugin remove operations skip these IDs.
|
||||
export const guarded: ReadonlySet<string> = new Set([OpencodePlugin.id, ConfigPolicyPlugin.Plugin.id])
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
// Capture only services; activation supplies the child Scope and batching context.
|
||||
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
|
||||
|
||||
@@ -7,7 +7,6 @@ import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
@@ -140,16 +139,18 @@ export const AzurePlugin = define({
|
||||
)
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
if (resourceName)
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
resourceName,
|
||||
...(typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
const websocket = responsesWebSocketCapable(item.provider)
|
||||
if (!resourceName && !websocket) continue
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(resourceName === undefined ? {} : { resourceName }),
|
||||
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
|
||||
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
@@ -167,10 +168,6 @@ export const AzurePlugin = define({
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: item.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -242,9 +239,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
|
||||
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
|
||||
}
|
||||
|
||||
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
|
||||
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = Provider.mergeOverlay(provider.settings, model.settings)
|
||||
function responsesWebSocketCapable(provider: Provider.Info) {
|
||||
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = provider.settings
|
||||
if (settings?.useDeploymentBasedUrls === true) return false
|
||||
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
|
||||
if (typeof settings?.baseURL !== "string") return true
|
||||
|
||||
@@ -254,10 +254,13 @@ export const OpenAIPlugin = define({
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
const item = providers.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
if (!chatgpt) return
|
||||
const account = chatgpt.metadata?.accountID
|
||||
const account = chatgpt?.metadata?.accountID
|
||||
providers.update(item.provider.id, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
...(chatgpt ? { baseURL: codexBaseURL } : {}),
|
||||
})
|
||||
if (!chatgpt) return
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
originator: "opencode",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
@@ -270,9 +273,6 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { ManagedPolicy } from "../../managed-policy.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
|
||||
@@ -20,6 +22,10 @@ const RemoteResponse = Schema.Struct({
|
||||
websearch: Schema.Struct({
|
||||
providerID: WebSearch.ID,
|
||||
}).pipe(Schema.optional),
|
||||
// Organization policy compiled for the authenticated caller; omitted when there is none.
|
||||
experimental: Schema.Struct({
|
||||
policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
})
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
@@ -112,38 +118,50 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
}
|
||||
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ManagedPolicy.Service | Scope.Scope>({
|
||||
id: "opencode.provider.opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
let snapshot: {
|
||||
config: typeof RemoteResponse.Type | undefined
|
||||
connection: ActiveConnection
|
||||
} = { config: undefined, connection: undefined }
|
||||
organization: string | undefined
|
||||
} = { config: undefined, connection: undefined, organization: undefined }
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.as(
|
||||
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
|
||||
? snapshot.config
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!connection) return { config: undefined, connection, organization: undefined }
|
||||
return yield* ctx.integration.connection.resolve(connection).pipe(
|
||||
Effect.flatMap((credential) => {
|
||||
if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined })
|
||||
return fetchConfig(http, credential).pipe(
|
||||
Effect.map((config) => ({
|
||||
config,
|
||||
connection,
|
||||
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
|
||||
})),
|
||||
)
|
||||
: undefined
|
||||
return { config, connection }
|
||||
}),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
// A load that fails for the connection already in place keeps its last config: dropping it
|
||||
// would lift organization policy while personal credentials keep working.
|
||||
Effect.as(
|
||||
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
|
||||
? { config: snapshot.config, connection, organization: snapshot.organization }
|
||||
: { config: undefined, connection, organization: undefined },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
// Statements ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too.
|
||||
const publish = (next: typeof snapshot) =>
|
||||
managed.set({ statements: next.config?.experimental?.policies ?? [], organization: next.organization })
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.update("opencode", (integration) => {
|
||||
@@ -154,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
})
|
||||
|
||||
snapshot = yield* load()
|
||||
yield* publish(snapshot)
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
|
||||
const source = providers.get(item.canonical ?? providerID)
|
||||
@@ -316,6 +335,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
||||
snapshot = next
|
||||
yield* publish(next)
|
||||
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
||||
|
||||
@@ -95,14 +95,13 @@ export const XAIPlugin = define({
|
||||
editor.method.update(device(ctx.app))
|
||||
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
if (!providers.get(providerID)) return
|
||||
providers.update(providerID, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
if (operation.type === "remove") {
|
||||
if (operation.target === "*") failures.clear()
|
||||
plugins()
|
||||
.filter((plugin) => matches(operation.target, plugin.id))
|
||||
.filter((plugin) => matches(operation.target, plugin.id) && !PluginInternal.guarded.has(plugin.id))
|
||||
.forEach((plugin) => enabled.delete(plugin.id))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -134,11 +134,16 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
|
||||
/** opencode settings consumed in Core; native packages never receive them. */
|
||||
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
|
||||
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
|
||||
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
return Struct.omit(settings, CORE_KEYS)
|
||||
}
|
||||
|
||||
export function modelSettings(settings: Settings | undefined) {
|
||||
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
base: Readonly<Record<string, unknown>> | undefined,
|
||||
overlay: Readonly<Record<string, unknown>> | undefined,
|
||||
|
||||
@@ -27,14 +27,15 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
Model.Info.make({
|
||||
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
|
||||
...Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
|
||||
modelID: Model.ID.make("api-model"),
|
||||
package: Provider.aisdk(packageName),
|
||||
settings,
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
|
||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
|
||||
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider and model overlay merges", () =>
|
||||
it.effect("keeps settings scoped while resolving request overlay merges", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
@@ -533,6 +533,7 @@ describe("Provider and Model", () => {
|
||||
})
|
||||
|
||||
const model = required(yield* models.get(providerID, modelID))
|
||||
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
|
||||
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
|
||||
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info, type Entry } from "@opencode/schema/config"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
@@ -13,15 +18,18 @@ import { PluginTestLayer } from "../plugin/fixture"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
experimental: {
|
||||
policies: items.map((item) => ({ action: "provider.use", ...item })),
|
||||
},
|
||||
}),
|
||||
})
|
||||
const document = (...policies: ConfigPolicy.Info[]) =>
|
||||
new Document({ type: "document", info: decode({ experimental: { policies } }) })
|
||||
const provider = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({
|
||||
action: "provider.use",
|
||||
resource,
|
||||
effect,
|
||||
})
|
||||
const permission = (effect: ConfigPolicy.Effect, resource: string): ConfigPolicy.Info => ({
|
||||
action: "permission",
|
||||
resource,
|
||||
effect,
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -29,6 +37,17 @@ const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
})
|
||||
|
||||
const evaluate = Effect.fn(function* (action: string, resources: string[], effect: Permission.Effect = "allow") {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("permission", "evaluate", {
|
||||
sessionID: Session.ID.make("ses_policy"),
|
||||
action,
|
||||
resources,
|
||||
effect,
|
||||
})
|
||||
return { effect: event.effect, message: event.message }
|
||||
})
|
||||
|
||||
describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -39,11 +58,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
catalog.update(Provider.ID.make("company-internal"), () => {})
|
||||
})
|
||||
yield* addPlugin([
|
||||
policies(
|
||||
{ effect: "deny", resource: "*" },
|
||||
{ effect: "allow", resource: "anthropic" },
|
||||
{ effect: "allow", resource: "company-*" },
|
||||
),
|
||||
document(provider("deny", "*"), provider("allow", "anthropic"), provider("allow", "company-*")),
|
||||
])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
@@ -56,10 +71,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
|
||||
yield* addPlugin([
|
||||
policies({ effect: "deny", resource: "openai" }),
|
||||
policies({ effect: "allow", resource: "openai" }),
|
||||
])
|
||||
yield* addPlugin([document(provider("deny", "openai")), document(provider("allow", "openai"))])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
}),
|
||||
@@ -76,10 +88,122 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host)
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
|
||||
yield* test.setEntries([document(provider("allow", "openai"))])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(catalog.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
|
||||
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
|
||||
}).pipe(Effect.provide(Config.testLayer([document(provider("deny", "openai"))]))),
|
||||
)
|
||||
|
||||
it.effect("denies permissions matched as action:resource", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "shell:git push *"))])
|
||||
|
||||
expect(yield* evaluate("shell", ["git push"])).toEqual({
|
||||
effect: "deny",
|
||||
message: "Blocked by configuration policy",
|
||||
})
|
||||
expect(yield* evaluate("shell", ["git push origin main"])).toEqual({
|
||||
effect: "deny",
|
||||
message: "Blocked by configuration policy",
|
||||
})
|
||||
// Compound commands check several resources; any denied resource denies the operation.
|
||||
expect((yield* evaluate("shell", ["git status", "git push"])).effect).toBe("deny")
|
||||
expect(yield* evaluate("shell", ["git status"])).toEqual({ effect: "allow", message: undefined })
|
||||
expect(yield* evaluate("edit", ["git push"])).toEqual({ effect: "allow", message: undefined })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("turns an ask into a deny but never grants", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "webfetch:*"), permission("allow", "shell:*"))])
|
||||
|
||||
expect((yield* evaluate("webfetch", ["https://example.com"], "ask")).effect).toBe("deny")
|
||||
expect((yield* evaluate("shell", ["ls"], "ask")).effect).toBe("ask")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets a later allow lift an earlier broad deny", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "shell:*"), permission("allow", "shell:git status *"))])
|
||||
|
||||
expect((yield* evaluate("shell", ["git status --short"])).effect).toBe("allow")
|
||||
expect((yield* evaluate("shell", ["rm -rf /"])).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("denies every permission with a bare wildcard", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin([document(permission("deny", "*"))])
|
||||
|
||||
expect((yield* evaluate("question", ["*"])).effect).toBe("deny")
|
||||
expect((yield* evaluate("read", ["/tmp/notes.txt"])).effect).toBe("deny")
|
||||
expect((yield* evaluate("github_delete_repository", ["*"])).effect).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates organization provider statements after every authored document", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.update(Provider.ID.openai, () => {})
|
||||
catalog.update(Provider.ID.anthropic, () => {})
|
||||
catalog.update(Provider.ID.opencode, () => {})
|
||||
})
|
||||
yield* managed.set({ statements: [provider("deny", "*"), provider("allow", "opencode")] })
|
||||
yield* addPlugin([document(provider("allow", "anthropic"))])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined()
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets an organization allow restore a provider denied by the user", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
|
||||
yield* managed.set({ statements: [provider("allow", "openai")] })
|
||||
yield* addPlugin([document(provider("deny", "openai"))])
|
||||
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("names the organization when its permission statement decides", () =>
|
||||
Effect.gen(function* () {
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* managed.set({ statements: [permission("deny", "shell:sudo *")], organization: "Acme" })
|
||||
yield* addPlugin([document(permission("allow", "shell:*"))])
|
||||
|
||||
expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ effect: "deny", message: "Blocked by Acme's policy" })
|
||||
expect((yield* evaluate("shell", ["ls"])).effect).toBe("allow")
|
||||
|
||||
yield* managed.set({ statements: [permission("deny", "shell:sudo *")] })
|
||||
expect(yield* evaluate("shell", ["sudo ls"])).toEqual({
|
||||
effect: "deny",
|
||||
message: "Blocked by your organization's policy",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebuilds the catalog from replaced organization statements", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {}))
|
||||
yield* addPlugin([])
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
|
||||
|
||||
yield* managed.set({ statements: [provider("deny", "openai")] })
|
||||
yield* catalog.reload()
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* managed.set({ statements: [] })
|
||||
yield* catalog.reload()
|
||||
expect(yield* catalog.get(Provider.ID.openai)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
const providers = yield* Provider.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
@@ -63,6 +64,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(native.settings?.compaction).toEqual({ type: "native" })
|
||||
expect(local.settings?.compaction).toEqual({ type: "summary" })
|
||||
expect(defaultModel.settings?.compaction).toBeUndefined()
|
||||
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
@@ -73,8 +75,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits the provider websocket policy with model overrides", () =>
|
||||
it.effect("keeps the provider websocket policy out of model settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -83,8 +86,12 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
settings: { transport: "http" },
|
||||
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
|
||||
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
|
||||
models: {
|
||||
inherited: {
|
||||
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
@@ -92,10 +99,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
])
|
||||
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
|
||||
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
|
||||
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
|
||||
expect(inherited.settings?.transport).toBe("http")
|
||||
expect(override.settings?.transport).toBe("websocket")
|
||||
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
|
||||
expect(inherited.settings).toEqual({ shared: "provider", model: true })
|
||||
expect(untouched.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -106,7 +112,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
]) {
|
||||
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
|
||||
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
@@ -122,7 +128,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
|
||||
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -131,15 +138,16 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
settings: { transport: "http" },
|
||||
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
|
||||
models: { override: { modelID: builtin.model } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
|
||||
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Generate } from "@opencode/core/generate"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { KV } from "@opencode/core/kv"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -85,6 +86,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
ManagedPolicy.node,
|
||||
Mcp.node,
|
||||
Session.node,
|
||||
PersistentPty.node,
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
|
||||
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
@@ -472,9 +472,8 @@ describe("AzurePlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
|
||||
expect(responses.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const model = required(yield* service.get(Provider.ID.azure, modelID))
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
}
|
||||
|
||||
@@ -205,7 +205,8 @@ describe("OpenAIPlugin", () => {
|
||||
expect(model.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(model.settings?.transport).toBe("websocket")
|
||||
expect(provider.settings?.transport).toBe("websocket")
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM } from "@opencode/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
@@ -628,6 +632,158 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enforces organization policy statements from the Console", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
providers: { opencode: {} },
|
||||
experimental: {
|
||||
policies: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "opencode", effect: "allow" },
|
||||
{ action: "permission", resource: "shell:sudo *", effect: "deny", audience: "ignored" },
|
||||
],
|
||||
unknown: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(Provider.ID.anthropic, () => {}))
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: server.url.origin, orgID: "org_test", orgName: "Acme" },
|
||||
}),
|
||||
})
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* OpencodePlugin.effect(host)
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer([])))
|
||||
|
||||
expect(managed.current()).toEqual({
|
||||
statements: [
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "opencode", effect: "allow" },
|
||||
{ action: "permission", resource: "shell:sudo *", effect: "deny" },
|
||||
],
|
||||
organization: "Acme",
|
||||
})
|
||||
expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined()
|
||||
expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined()
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps policy statements bound to the connected Console account", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state: { status: number; policies: Record<string, unknown[]>; requests: number } = {
|
||||
status: 200,
|
||||
policies: {},
|
||||
requests: 0,
|
||||
}
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
state.requests++
|
||||
if (state.status !== 200) return new Response("Unavailable", { status: state.status })
|
||||
const policies = state.policies[request.headers.get("x-org-id") ?? ""]
|
||||
return Response.json({ providers: {}, ...(policies ? { experimental: { policies } } : {}) })
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const providers = yield* Provider.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const rebuilds = { count: 0 }
|
||||
const account = (orgID: string, orgName: string) =>
|
||||
credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: orgID,
|
||||
metadata: { server: server.url.origin, orgID, orgName },
|
||||
}),
|
||||
})
|
||||
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
|
||||
const env: ConfigPolicy.Info = { action: "permission", resource: "edit:*.env", effect: "deny" }
|
||||
yield* providers.transform(() => {
|
||||
rebuilds.count++
|
||||
})
|
||||
const alpha = yield* account("org_alpha", "Alpha")
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
const initial = rebuilds.count
|
||||
expect(state.requests).toBe(1)
|
||||
expect(managed.current()).toEqual({ statements: [], organization: "Alpha" })
|
||||
|
||||
state.policies.org_alpha = [sudo]
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(2)
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
|
||||
expect(rebuilds.count).toBe(initial + 1)
|
||||
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(3)
|
||||
expect(rebuilds.count).toBe(initial + 1)
|
||||
|
||||
// An outage for the same connection keeps the last statements instead of lifting them.
|
||||
state.status = 503
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
|
||||
expect(rebuilds.count).toBe(initial + 1)
|
||||
|
||||
state.status = 200
|
||||
state.policies.org_beta = [env]
|
||||
const beta = yield* account("org_beta", "Beta")
|
||||
yield* eventually(
|
||||
Effect.sync(() => managed.current()),
|
||||
(current) => current.organization === "Beta",
|
||||
)
|
||||
expect(managed.current()).toEqual({ statements: [env], organization: "Beta" })
|
||||
|
||||
state.status = 404
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(managed.current()).toEqual({ statements: [], organization: "Beta" })
|
||||
|
||||
state.status = 200
|
||||
yield* credentials.remove(beta.id)
|
||||
yield* eventually(
|
||||
Effect.sync(() => managed.current()),
|
||||
(current) => current.organization === "Alpha",
|
||||
)
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" })
|
||||
|
||||
yield* credentials.remove(alpha.id)
|
||||
yield* eventually(
|
||||
Effect.sync(() => managed.current()),
|
||||
(current) => current.organization === undefined,
|
||||
)
|
||||
expect(managed.current()).toEqual({ statements: [], organization: undefined })
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -83,8 +83,10 @@ describe("XAIPlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = yield* providers.get(providerID)
|
||||
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(model?.settings?.transport).toBe("websocket")
|
||||
expect(provider?.settings?.transport).toBe("websocket")
|
||||
expect(model?.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,14 +32,14 @@ const greeter = (command: string, plugin: string = id) =>
|
||||
})
|
||||
|
||||
// Every supervisor activation scans the config plugin operations once, so counting scans counts activations.
|
||||
const source = { activations: 0 }
|
||||
const source: { activations: number; operations: ConfigPluginSource.Operation[] } = { activations: 0, operations: [] }
|
||||
const sourceLayer = Layer.succeed(
|
||||
ConfigPluginSource.Service,
|
||||
ConfigPluginSource.Service.of({
|
||||
operations: () =>
|
||||
Effect.sync(() => {
|
||||
source.activations++
|
||||
return []
|
||||
return source.operations
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
@@ -202,6 +202,36 @@ describe("PluginSupervisor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const targets of [["opencode.config.policy", "opencode.provider.opencode", "opencode.config.agent"], ["*"]]) {
|
||||
it.effect(`keeps policy enforcement and the Console connection through plugin removals: ${targets}`, () =>
|
||||
Effect.gen(function* () {
|
||||
source.operations = targets.map((target) => ({ type: "remove", target }))
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const inventory = yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
const status = (id: string) => inventory.find((plugin) => plugin.id === id)?.state.status
|
||||
expect(status("opencode.config.policy")).toBe("active")
|
||||
expect(status("opencode.provider.opencode")).toBe("active")
|
||||
// Removals still apply to everything else, including instance and builtin plugins.
|
||||
expect(status("opencode.config.agent")).toBeUndefined()
|
||||
expect(inventory.some((plugin) => plugin.id === id)).toBe(targets[0] !== "*")
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
source.operations = []
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("coalesces a burst of reload triggers after the initial generation into one activation", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
|
||||
@@ -42,4 +42,19 @@ describe("Provider", () => {
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("inherits shared and loose settings without provider-only policies", () => {
|
||||
expect(
|
||||
Provider.modelSettings({
|
||||
timeout: 60_000,
|
||||
chunkTimeout: 30_000,
|
||||
transport: "websocket",
|
||||
compaction: { type: "native" },
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
compaction: { type: "native" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
|
||||
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
|
||||
// [--trace] [--out <dir>] [--home <dir>]
|
||||
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
|
||||
//
|
||||
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
|
||||
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
|
||||
@@ -21,7 +21,15 @@
|
||||
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
|
||||
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
|
||||
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
|
||||
import { spawn } from "node:child_process"
|
||||
//
|
||||
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
|
||||
// without activating it the moment it exists, then records when the window's pixels first differ
|
||||
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
|
||||
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
|
||||
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
|
||||
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
|
||||
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -45,6 +53,9 @@ const args = parseArgs({
|
||||
"profile-renderer": { type: "boolean", default: false },
|
||||
trace: { type: "boolean", default: false },
|
||||
"settle-ms": { type: "string", default: "1500" },
|
||||
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
|
||||
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
|
||||
"window-at": { type: "string" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
@@ -112,10 +123,12 @@ const probe = `(() => ({
|
||||
origin: performance.timeOrigin,
|
||||
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
|
||||
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
|
||||
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('[data-timeline-row]').length,
|
||||
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
|
||||
prepaint: !!document.getElementById('oc-prepaint'),
|
||||
visible: document.visibilityState === 'visible',
|
||||
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('#root [data-timeline-row]').length,
|
||||
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
|
||||
url: location.pathname + location.search,
|
||||
}))()`
|
||||
// Main-process bootstrap timing, read after the run: when the process was created, when Node
|
||||
@@ -237,6 +250,8 @@ type Probe = {
|
||||
origin: number
|
||||
firstPaint?: number
|
||||
domInteractive?: number
|
||||
prepaint: boolean
|
||||
visible: boolean
|
||||
shell: boolean
|
||||
editor: boolean
|
||||
rows: number
|
||||
@@ -273,6 +288,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const trace = args.values.trace && run === runs
|
||||
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
|
||||
const launchArgs = [
|
||||
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
|
||||
...(trace
|
||||
@@ -284,10 +300,12 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const raiser = await windowRaiser()
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
|
||||
child.unref()
|
||||
appPid = child.pid
|
||||
raiser.raise(child.pid!)
|
||||
|
||||
let mainProfile: Promise<unknown> | undefined
|
||||
if (profile) mainProfile = profileMain(spawnAt)
|
||||
@@ -309,6 +327,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
|
||||
const seen: Record<string, number> = {}
|
||||
let last: Probe | undefined
|
||||
let prepaintSeen = false
|
||||
let quietSince: number | undefined
|
||||
let taskMs = 0
|
||||
let scriptMs = 0
|
||||
@@ -318,6 +337,9 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
|
||||
last = result.result?.result?.value as Probe | undefined
|
||||
const t = Date.now() - spawnAt
|
||||
if (last?.prepaint) prepaintSeen = true
|
||||
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
|
||||
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
|
||||
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
|
||||
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
|
||||
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
|
||||
@@ -356,6 +378,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
|
||||
cdp.close()
|
||||
const processes = appPid ? await processTree(appPid) : []
|
||||
const screenChanges = await raiser.screen()
|
||||
const boot = profile ? undefined : await mainBootTiming()
|
||||
await sleep(300)
|
||||
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
|
||||
@@ -385,7 +408,16 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
domInteractive:
|
||||
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
|
||||
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
|
||||
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
|
||||
prepaintVisible:
|
||||
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
|
||||
? Math.round(origin + last.firstPaint)
|
||||
: undefined,
|
||||
...seen,
|
||||
// First sampled screen change inside the window: the ground truth for "the user sees something".
|
||||
screenPainted: screenChanges.changed[0],
|
||||
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
|
||||
screenSettled: screenChanges.settled,
|
||||
rendererIdle: rendererIdleMs,
|
||||
},
|
||||
final: { url: last?.url, timelineRows: last?.rows },
|
||||
@@ -451,6 +483,22 @@ function prepareHome() {
|
||||
})
|
||||
}
|
||||
mkdirSync(paths.logs, { recursive: true })
|
||||
const at = args.values["window-at"]?.split(",").map(Number)
|
||||
if (at?.length === 2 && existsSync(userData)) {
|
||||
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
|
||||
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
|
||||
const bounds = displayAt(at[0], at[1])
|
||||
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayAt(x: number, y: number) {
|
||||
if (process.platform !== "win32") return undefined
|
||||
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
|
||||
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
|
||||
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
|
||||
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
@@ -565,6 +613,86 @@ function mainLog() {
|
||||
}
|
||||
|
||||
// Working set of every process in the launched app's tree once it is idle.
|
||||
// Windows places a window launched from a background process behind the foreground one, and
|
||||
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
|
||||
// what else is on screen. A helper started before the app polls for its main window and pins it
|
||||
// topmost without activating it, so the user keeps their focus and the bench window is visible.
|
||||
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
|
||||
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
|
||||
const script = join(outDir, "raise-window.ps1")
|
||||
writeFileSync(
|
||||
script,
|
||||
[
|
||||
`Add-Type -AssemblyName System.Drawing`,
|
||||
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
|
||||
`[Console]::Out.WriteLine("ready")`,
|
||||
`$target = [int][Console]::In.ReadLine()`,
|
||||
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
|
||||
`$h = 0`,
|
||||
`for ($i = 0; $i -lt 600; $i++) {`,
|
||||
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
|
||||
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
|
||||
` Start-Sleep -Milliseconds 10`,
|
||||
`}`,
|
||||
`if (-not $h -or $h -eq 0) { exit }`,
|
||||
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
|
||||
// colour, a splash and the interface apart.
|
||||
`$r = New-Object Bench.User32+RECT`,
|
||||
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
|
||||
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
|
||||
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
|
||||
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
|
||||
` $t = $clock.ElapsedMilliseconds`,
|
||||
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
|
||||
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
|
||||
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
|
||||
` $sum = 0`,
|
||||
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
|
||||
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
|
||||
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
|
||||
` $g.Dispose(); $bmp.Dispose()`,
|
||||
` Start-Sleep -Milliseconds 30`,
|
||||
`}`,
|
||||
].join("\n"),
|
||||
)
|
||||
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
|
||||
const lines: string[] = []
|
||||
let buffer = ""
|
||||
await new Promise<void>((resolve) => {
|
||||
helper.stdout!.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString()
|
||||
const parts = buffer.split(/\r?\n/)
|
||||
buffer = parts.pop() ?? ""
|
||||
for (const line of parts) {
|
||||
if (line === "ready") resolve()
|
||||
else lines.push(line)
|
||||
}
|
||||
})
|
||||
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
|
||||
helper.on("exit", () => resolve())
|
||||
})
|
||||
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
|
||||
return {
|
||||
raise: (pid) => helper.stdin!.write(`${pid}\n`),
|
||||
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
|
||||
// content differed from the first sample, i.e. when something other than the background colour
|
||||
// was on screen.
|
||||
screen: async () => {
|
||||
await exited
|
||||
const samples = lines
|
||||
.filter((line) => line.startsWith("screen "))
|
||||
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
|
||||
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
|
||||
const first = samples[0]?.[1]
|
||||
const last = samples.at(-1)?.[1]
|
||||
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
|
||||
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
|
||||
// The last sample that still differed from the final content, i.e. when the window stopped changing.
|
||||
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
|
||||
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
|
||||
},
|
||||
}
|
||||
}
|
||||
async function processTree(root: number) {
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { listInstalledWslDistros, probeWslRuntime, runWslSh, wslArgs } from "./runtime"
|
||||
|
||||
test("wslArgs bypasses the distro default shell", () => {
|
||||
expect(wslArgs(["sh", "-lc", 'printf "%s\\n" "$HOME"'], "Debian")).toEqual([
|
||||
"-d",
|
||||
"Debian",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'printf "%s\\n" "$HOME"',
|
||||
])
|
||||
expect(wslArgs(["bash", "-se"], "Debian", "root")).toEqual([
|
||||
"-d",
|
||||
"Debian",
|
||||
"--user",
|
||||
"root",
|
||||
"--exec",
|
||||
"bash",
|
||||
"-se",
|
||||
])
|
||||
})
|
||||
|
||||
// Exercise the real wsl.exe argv path: with `--`, WSL routes the command line through the distro's
|
||||
// default shell, which expands `$cli` to "" before `sh` runs the script (#48640).
|
||||
const distro =
|
||||
process.platform === "win32" && (await probeWslRuntime()).available
|
||||
? (await listInstalledWslDistros().catch(() => [])).find((item) => item.isDefault)?.name
|
||||
: undefined
|
||||
|
||||
test.skipIf(!distro)("inline scripts keep their own variable expansion inside WSL", async () => {
|
||||
const result = await runWslSh(['cli="from-script"', 'printf "%s\\n" "$cli"'].join("\n"), distro)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("from-script")
|
||||
})
|
||||
@@ -38,7 +38,9 @@ const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
|
||||
// `--` hands the command line to the distro's default shell, which expands `$VAR` and `$(...)`
|
||||
// inside our inline scripts before `sh -lc` ever runs them. `--exec` runs the command directly.
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--exec", ...args]
|
||||
}
|
||||
|
||||
export function runWsl(args: string[], opts: RunWslOptions = {}) {
|
||||
|
||||
@@ -78,11 +78,15 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
const ssh = useSsh()
|
||||
const sshConnections = createSshConnections(props.api.sshServers)
|
||||
const language = useLanguage()
|
||||
// The shell mounts from local state as soon as the quick lookups resolve. The local service,
|
||||
// WSL and SSH connections are discovered meanwhile and appear in the server list when ready;
|
||||
// until then the list is marked pending so nothing acts on it being empty.
|
||||
const ready = createMemo(() => !firstLaunch.loading && !defaultServer.loading && !locale.loading)
|
||||
const serversPending = createMemo(() => sidecar.loading || wslServers.isLoading || ssh.loading)
|
||||
const ready = createMemo(
|
||||
() =>
|
||||
!firstLaunch.loading &&
|
||||
!defaultServer.loading &&
|
||||
!sidecar.loading &&
|
||||
!locale.loading &&
|
||||
!wslServers.isLoading &&
|
||||
!ssh.loading,
|
||||
)
|
||||
const servers = createMemo(() => {
|
||||
const data = initializationData(sidecar)
|
||||
const list: ServerConnection.Any[] = []
|
||||
@@ -105,31 +109,30 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
|
||||
return (
|
||||
<Show when={ready()}>
|
||||
<AppInterface
|
||||
defaultServer={effectiveDefaultServer()}
|
||||
servers={servers()}
|
||||
serversPending={serversPending()}
|
||||
router={router}
|
||||
>
|
||||
<DesktopStartupReady
|
||||
routeReady={!initialRoute.loading && startup.onboardingReady}
|
||||
onReady={() => setStartup("ready", true)}
|
||||
onRoute={(route) => setStartup("route", route)}
|
||||
/>
|
||||
<DesktopFirstLaunchOnboarding
|
||||
api={props.api}
|
||||
initialUrl={initialUrl}
|
||||
serverKey={effectiveDefaultServer()}
|
||||
pending={firstLaunch() ?? false}
|
||||
onReady={() => setStartup("onboardingReady", true)}
|
||||
/>
|
||||
<DesktopEffects api={props.api} />
|
||||
<Suspense fallback={null}>
|
||||
<Show when={initializationData(sidecar)} keyed>
|
||||
{(server) => <MigrationStatus server={server} />}
|
||||
</Show>
|
||||
</Suspense>
|
||||
</AppInterface>
|
||||
<Show when={effectiveDefaultServer()} keyed>
|
||||
{(key) => (
|
||||
<AppInterface defaultServer={key} servers={servers()} router={router}>
|
||||
<DesktopStartupReady
|
||||
routeReady={!initialRoute.loading && startup.onboardingReady}
|
||||
onReady={() => setStartup("ready", true)}
|
||||
onRoute={(route) => setStartup("route", route)}
|
||||
/>
|
||||
<DesktopFirstLaunchOnboarding
|
||||
api={props.api}
|
||||
initialUrl={initialUrl}
|
||||
serverKey={key}
|
||||
pending={firstLaunch() ?? false}
|
||||
onReady={() => setStartup("onboardingReady", true)}
|
||||
/>
|
||||
<DesktopEffects api={props.api} />
|
||||
<Suspense fallback={null}>
|
||||
<Show when={initializationData(sidecar)} keyed>
|
||||
{(server) => <MigrationStatus server={server} />}
|
||||
</Show>
|
||||
</Suspense>
|
||||
</AppInterface>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop"
|
||||
import { createEffect, createResource, createRoot } from "solid-js"
|
||||
import { createResource } from "solid-js"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
|
||||
export function DesktopFirstLaunchOnboarding(props: {
|
||||
@@ -24,16 +24,6 @@ export function DesktopFirstLaunchOnboarding(props: {
|
||||
if (!props.pending) return
|
||||
|
||||
await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve()))
|
||||
// The shell mounts before the local service has connected; the decision needs the full list.
|
||||
await new Promise<void>((resolve) =>
|
||||
createRoot((dispose) =>
|
||||
createEffect(() => {
|
||||
if (server.pending) return
|
||||
dispose()
|
||||
resolve()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const shouldTrigger =
|
||||
props.initialUrl === "/" &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -13251,6 +13251,27 @@
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13270,7 +13291,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -13293,7 +13314,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15508,7 +15529,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15614,6 +15635,20 @@
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Model.Variant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15621,7 +15656,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -17,6 +17,14 @@ export const Settings = Schema.StructWithRest(
|
||||
).annotate({ identifier: "Config.Provider.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
export const ModelSettings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
|
||||
).annotate({ identifier: "Config.Model.Settings" })
|
||||
export type ModelSettings = typeof ModelSettings.Type
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
@@ -25,6 +33,12 @@ export const Overlays = {
|
||||
body: JsonRecord.pipe(optional),
|
||||
}
|
||||
|
||||
const ModelOverlays = {
|
||||
settings: ModelSettings.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: JsonRecord.pipe(optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
headers: Overlays.headers,
|
||||
body: Overlays.body,
|
||||
@@ -57,11 +71,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
|
||||
name: Schema.String.pipe(optional),
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
...Overlays,
|
||||
...ModelOverlays,
|
||||
capabilities: Capabilities.pipe(optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...Overlays,
|
||||
...ModelOverlays,
|
||||
}).pipe(Schema.Array, optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
|
||||
@@ -56,6 +56,21 @@ export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tok
|
||||
})
|
||||
export type MaxTokensField = typeof MaxTokensField.Type
|
||||
|
||||
export const Settings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
}),
|
||||
// Provider packages may define arbitrary model-level options beyond OpenCode's shared compaction policy.
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
).annotate({ identifier: "Model.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
export const Overlays = {
|
||||
settings: Settings.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
}
|
||||
|
||||
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
|
||||
export const Compatibility = Schema.Struct({
|
||||
reasoningField: ReasoningField.pipe(optional),
|
||||
@@ -97,7 +112,7 @@ export const Cost = Schema.Struct({
|
||||
export interface Variant extends Schema.Schema.Type<typeof Variant> {}
|
||||
export const Variant = Schema.Struct({
|
||||
id: VariantID,
|
||||
...Provider.Overlays,
|
||||
...Overlays,
|
||||
}).annotate({ identifier: "Model.Variant" })
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
@@ -110,7 +125,7 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Provider.Package.pipe(optional),
|
||||
...Provider.Overlays,
|
||||
...Overlays,
|
||||
capabilities: Capabilities,
|
||||
variants: Schema.Array(Variant),
|
||||
time: Schema.Struct({
|
||||
|
||||
@@ -169,6 +169,7 @@ describe("contract hygiene", () => {
|
||||
test("reusable public identifiers are stable and unique", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
ConfigProvider.ModelSettings,
|
||||
ConfigProvider.Settings,
|
||||
FileSystem.Submatch,
|
||||
Form.Field,
|
||||
@@ -183,6 +184,7 @@ describe("contract hygiene", () => {
|
||||
Model.Ref,
|
||||
Model.Capabilities,
|
||||
Model.Cost,
|
||||
Model.Settings,
|
||||
Model.Variant,
|
||||
Project.Current,
|
||||
Worktree.Directory,
|
||||
@@ -239,11 +241,12 @@ describe("contract hygiene", () => {
|
||||
|
||||
expect(
|
||||
sources
|
||||
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
|
||||
.filter((item) => item.file !== "provider.ts" && item.file !== "model.ts" && item.file !== "integration.ts")
|
||||
.map((item) => item.source)
|
||||
.join("\n"),
|
||||
).not.toContain("Schema.Any")
|
||||
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
|
||||
expect(sources.find((item) => item.file === "model.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
|
||||
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("Schema.mutable")
|
||||
})
|
||||
|
||||
@@ -75,13 +75,10 @@ describe("Model.Info", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model.Capabilities", () => {
|
||||
test("decodes the optional transport setting", () => {
|
||||
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
|
||||
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
|
||||
transport: "websocket",
|
||||
describe("Model.Settings", () => {
|
||||
test("preserves provider-specific model options", () => {
|
||||
expect(Schema.decodeUnknownSync(Model.Settings)({ providerOption: true })).toEqual({
|
||||
providerOption: true,
|
||||
})
|
||||
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { typeLabel } from "./message-file"
|
||||
import type { PromptFileAttachment } from "@opencode/client/promise"
|
||||
import { attached, typeLabel } from "./message-file"
|
||||
|
||||
function file(source: PromptFileAttachment["source"], mention = false): PromptFileAttachment {
|
||||
return {
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source,
|
||||
...(mention ? { mention: { text: "@README.md", start: 0, end: 10 } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
describe("message-file", () => {
|
||||
test("only treats data-backed files without mentions as attachments", () => {
|
||||
expect(attached(file({ type: "inline" }))).toBe(true)
|
||||
expect(attached(file({ type: "uri", uri: "data:text/plain;base64,SGVsbG8=" }))).toBe(true)
|
||||
expect(attached(file({ type: "uri", uri: "file:///repo/README.md" }))).toBe(false)
|
||||
expect(attached(file({ type: "inline" }, true))).toBe(false)
|
||||
})
|
||||
|
||||
test("labels attachment types from the basename extension", () => {
|
||||
expect(typeLabel("list.md", "text/plain", "File")).toBe("Markdown")
|
||||
expect(typeLabel("/repo/src/main.ts", "text/plain", "File")).toBe("TypeScript")
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { bundledLanguagesInfo } from "shiki"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import type { PromptFileAttachment } from "@opencode/client/promise"
|
||||
|
||||
export function attached(file: PromptFileAttachment) {
|
||||
if (file.mention) return false
|
||||
if (file.source.type === "inline") return true
|
||||
return file.source.uri.startsWith("data:")
|
||||
}
|
||||
|
||||
// language metadata only; grammars stay behind shiki's lazy imports
|
||||
const LANGUAGE_NAMES = new Map<string, string>(
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
SessionMessageUser,
|
||||
} from "@opencode/client/promise"
|
||||
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
|
||||
import { typeLabel } from "../components/message-file"
|
||||
import { attached, typeLabel } from "../components/message-file"
|
||||
|
||||
export async function writeClipboard(text: string): Promise<boolean> {
|
||||
const body = typeof document === "undefined" ? undefined : document.body
|
||||
@@ -214,7 +214,7 @@ export function CurrentUserMessageDisplay(props: {
|
||||
const dialog = useDialog()
|
||||
const i18n = useI18n()
|
||||
const [state, setState] = createStore({ copied: false, reverting: false })
|
||||
const attachments = createMemo(() => (props.message.files ?? []).filter((file) => !file.mention))
|
||||
const attachments = createMemo(() => (props.message.files ?? []).filter(attached))
|
||||
const references = createMemo(() => props.references ?? [])
|
||||
const inlineFiles = createMemo(() => (props.message.files ?? []).filter((file) => !!file.mention))
|
||||
const agents = createMemo(() => props.message.agents ?? [])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -120,7 +120,7 @@ const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, lev
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
|
||||
|
||||
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
|
||||
if (status.attention) return theme.hue.interactive[800]
|
||||
if (status.attention) return theme.hue.accent[200]
|
||||
if (status.unread === "error") return theme.text.feedback.error.base
|
||||
return undefined
|
||||
}
|
||||
@@ -528,8 +528,8 @@ function VerticalSessionTabs(props: {
|
||||
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
|
||||
const tooltipWidth = () => Math.min(54, dimensions().width - width())
|
||||
const stride = () => (compact() ? 2 : 3)
|
||||
const unreadColor = () => theme.hue.interactive[800]
|
||||
const activeNumber = () => theme.hue.accent[800]
|
||||
const unreadColor = () => theme.hue.accent[200]
|
||||
const activeNumber = () => theme.hue.interactive[200]
|
||||
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
|
||||
const separatorUpperPulseColor = createMemo(() => tint(background(), theme.text.base, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(background(), theme.text.base, 0.05))
|
||||
@@ -1291,8 +1291,8 @@ function HorizontalSessionTabs(props: {
|
||||
onCleanup(clearCloseHold)
|
||||
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
|
||||
let suppressClick = false
|
||||
const unreadColor = () => theme.hue.interactive[800]
|
||||
const activeNumber = () => theme.hue.accent[800]
|
||||
const unreadColor = () => theme.hue.accent[200]
|
||||
const activeNumber = () => theme.hue.interactive[200]
|
||||
const idleNumber = () => tint(theme.text.formfield.base, theme.background.base, 0.55)
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
|
||||
@@ -32,7 +32,7 @@ export default Plugin.define({
|
||||
return (
|
||||
<Show when={pending() > 0}>
|
||||
<box flexShrink={0}>
|
||||
<Spinner color={theme.hue.accent[800]}>/btw</Spinner>
|
||||
<Spinner color={theme.hue.interactive[200]}>/btw</Spinner>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.hue.accent[800]}
|
||||
color={theme.hue.interactive[200]}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
@@ -236,7 +236,7 @@ function OneCellSpinnerStory(props: { context: Plugin.Context }) {
|
||||
animations={animations()}
|
||||
paused={paused()}
|
||||
glow={glow()}
|
||||
color={theme.hue.accent[800]}
|
||||
color={theme.hue.interactive[200]}
|
||||
/>
|
||||
<text fg={theme.text.base}> esc stop</text>
|
||||
</box>
|
||||
|
||||
@@ -284,10 +284,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
{ text: "", color: theme.text.base },
|
||||
)
|
||||
if (status.attention === "question")
|
||||
lines.push({ text: "? Which approach should I take?", color: theme.hue.interactive[800] })
|
||||
lines.push({ text: "? Which approach should I take?", color: theme.hue.accent[200] })
|
||||
else if (status.attention === "permission")
|
||||
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.interactive[800] })
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.accent[800] })
|
||||
lines.push({ text: "! Waiting for permission to run the command", color: theme.hue.accent[200] })
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.hue.interactive[200] })
|
||||
else if (outcome === "failed")
|
||||
lines.push({
|
||||
text: `✗ bun run test failed — 3 tests failing in ${file}`,
|
||||
|
||||
@@ -142,9 +142,9 @@ function map(
|
||||
formfieldFocusedBg: exact(theme.background.formfield.focused),
|
||||
formfieldFocusedText: exact(theme.text.formfield.focused),
|
||||
selection: exact(theme.text.formfield.selected),
|
||||
running: exact(theme.hue.accent[800]),
|
||||
question: exact(theme.hue.interactive[800]),
|
||||
permission: exact(theme.hue.interactive[800]),
|
||||
running: exact(theme.hue.interactive[200]),
|
||||
question: exact(theme.hue.accent[200]),
|
||||
permission: exact(theme.hue.accent[200]),
|
||||
success: exact(theme.text.feedback.success.base),
|
||||
link: exact(theme.markdown.link),
|
||||
categorical: dedupeWith(
|
||||
|
||||
@@ -139,7 +139,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === (attention === "question" ? "?" : "!"))?.fg
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.hue.accent[200].toInts())
|
||||
const glow = () => {
|
||||
const colors = app
|
||||
.captureSpans()
|
||||
@@ -156,7 +156,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
expect(full).toBeGreaterThan(0)
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.hue.interactive[800].toInts())
|
||||
expect(indicatorColor()?.toInts()).toEqual(theme.hue.accent[200].toInts())
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
@@ -172,7 +172,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color?.toInts()).toEqual(
|
||||
(unread === "error" ? theme.text.feedback.error.base : theme.hue.interactive[800]).toInts(),
|
||||
(unread === "error" ? theme.text.feedback.error.base : theme.hue.accent[200]).toInts(),
|
||||
)
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
await app.renderOnce()
|
||||
|
||||
@@ -90,9 +90,9 @@ function expectFooter(actual: RunTheme, theme: ResolvedTheme) {
|
||||
formfieldFocusedBg: theme.background.formfield.focused,
|
||||
formfieldFocusedText: theme.text.formfield.focused,
|
||||
selection: theme.text.formfield.selected,
|
||||
running: theme.hue.accent[800],
|
||||
question: theme.hue.interactive[800],
|
||||
permission: theme.hue.interactive[800],
|
||||
running: theme.hue.interactive[200],
|
||||
question: theme.hue.accent[200],
|
||||
permission: theme.hue.accent[200],
|
||||
success: theme.text.feedback.success.base,
|
||||
link: theme.markdown.link,
|
||||
shade: theme.background.raised.base,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -296,7 +296,6 @@ export default defineConfig({
|
||||
],
|
||||
components: {
|
||||
Hero: "./src/components/Hero.astro",
|
||||
Banner: "./src/components/Banner.astro",
|
||||
Head: "./src/components/Head.astro",
|
||||
Header: "./src/components/Header.astro",
|
||||
Footer: "./src/components/Footer.astro",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<a class="v2-banner" href="https://opencode.ai/v2">
|
||||
<span class="badge">New</span>
|
||||
<span class="text">OpenCode v2 is now available</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.v2-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-strong) !important;
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.v2-banner:hover {
|
||||
background: var(--color-background-interactive-weaker);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--color-text);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.v2-banner:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.v2-banner {
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.8",
|
||||
"version": "2.0.9",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user