Compare commits

..
98 changed files with 2085 additions and 2998 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2'
if: github.ref_name == 'v2' || github.ref_name == 'beta'
working-directory: packages/updates
run: bun run deploy
env:
+4 -22
View File
@@ -358,7 +358,6 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -424,7 +423,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -577,7 +575,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -588,7 +585,6 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -607,21 +603,6 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -691,7 +672,6 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -991,6 +971,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1142,6 +1123,7 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -2161,8 +2143,6 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -5191,6 +5171,8 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
}
}
+2
View File
@@ -27,6 +27,7 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -92,6 +93,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
-10
View File
@@ -4,16 +4,6 @@ export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
BrowserPaneCommand,
BrowserPaneEndpoint,
BrowserPaneEvent,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneState,
BrowserPaneTarget,
} from "./runtime/platform/browser-pane"
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useTabs } from "./shell/tabs/tabs"
export { createDraftStore } from "./runtime/persistence/drafts"
+1 -11
View File
@@ -665,11 +665,9 @@ export const dict = {
"session.tab.usage": "Usage",
"session.view.select": "Session view",
"session.tab.review": "Review",
"session.tab.browser": "Browser",
"session.tab.add": "Add tab",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review, files, and browser",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
@@ -808,9 +806,6 @@ export const dict = {
"PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.",
"terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}",
"session.browser.address": "Browser address",
"session.browser.address.placeholder": "Enter a URL",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.updateVersion": "Update {{version}}",
@@ -980,8 +975,6 @@ export const dict = {
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
"settings.general.row.browserPane.title": "Browser pane",
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
@@ -1171,9 +1164,6 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Search the web",
"settings.permissions.tool.browser_read.description": "Read pages and capture screenshots in the browser",
"settings.permissions.tool.browser_navigate.description": "Navigate the browser to a URL",
"settings.permissions.tool.browser_interact.description": "Click, type, and interact with pages in the browser",
"settings.permissions.tool.external_directory.title": "External Directory",
"settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop",
@@ -1,19 +0,0 @@
import type { Browser } from "@opencode-ai/schema/browser"
export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>
export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }>
export type BrowserPaneLayout = { visible: boolean; bounds?: { x: number; y: number; width: number; height: number } }
export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent = { type: "open" } | { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
close(): void
}
export type BrowserPanePlatform = {
register(target: BrowserPaneTarget, listener: (event: BrowserPaneEvent) => void): BrowserPaneRegistration
}
@@ -6,7 +6,6 @@ import { ServerConnection } from "@/runtime/server/registry"
import type { WslServersPlatform } from "@/servers/wsl/types"
import type { UpdaterPlatform } from "@/shell/updates/types"
import type { DraftStore } from "@/runtime/persistence/drafts"
import type { BrowserPanePlatform } from "./browser-pane"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -116,9 +115,6 @@ type PlatformBase = {
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}
export type Platform = PlatformBase &
-100
View File
@@ -1,100 +0,0 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import { useServer } from "@/runtime/server/current"
import { useSettings } from "@/settings/model"
import type { SessionModel } from "../model"
import { SESSION_BROWSER_TAB } from "../helpers"
export function createSessionBrowser(session: SessionModel) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const server = useServer()
const [state, setState] = createStore({
registration: undefined as BrowserPaneRegistration | undefined,
browser: null as BrowserPaneState,
error: undefined as string | undefined,
// The connected server has no browser plugin.
unsupported: false,
})
const available = createMemo(
() =>
!!platform.browserPane &&
settings.ready() &&
settings.general.experimentalBrowser() &&
session.isDesktop() &&
!!session.identity.sessionID() &&
!server.health?.incompatible &&
!state.unsupported,
)
const opened = () => state.registration !== undefined && session.layout.tabs().all().includes(SESSION_BROWSER_TAB)
const open = () => {
session.layout.view().reviewPanel.open()
void session.layout.tabs().open(SESSION_BROWSER_TAB)
session.layout.tabs().setActive(SESSION_BROWSER_TAB)
}
createEffect(() => {
const sessionID = session.identity.sessionID()
const pane = platform.browserPane
setState({ registration: undefined, browser: null, error: undefined })
if (!available() || !sessionID || !pane) return
const owner = session.ownership.capture()
const target = { sessionID, endpoint: server.conn.http }
let registration: BrowserPaneRegistration | undefined
let retry: ReturnType<typeof setTimeout> | undefined
let attempts = 0
const register = () => {
if (registration) return
registration = pane.register(target, (event) =>
owner.run(() => {
if (event.type === "open") return open()
if (event.error === "browser.pane.unsupported") return setState("unsupported", true)
// The desktop dropped the attachment (server restart, attach race).
// Re-register so the agent's browser tool comes back without a reload.
if (event.error === "browser.pane.registration.closed") {
registration?.close()
registration = undefined
setState({ registration: undefined, browser: null, error: undefined })
retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** attempts++))
return
}
if (event.state) attempts = 0
setState({ browser: event.state, error: event.error })
}),
)
setState({ registration, browser: null, error: undefined })
}
// A new session appears in the UI before its server-side creation finishes.
const unsubscribe = session.shared.data.on("session.created", (event) => {
if (event.data.sessionID === sessionID) register()
})
if (!session.shared.data.session.creating(sessionID)) register()
onCleanup(() => {
unsubscribe()
clearTimeout(retry)
registration?.close()
})
})
return {
available,
opened,
state: () => state.browser,
error: () => state.error,
registration: () => state.registration,
close: () => session.layout.tabs().close(SESSION_BROWSER_TAB),
open,
command(command: BrowserPaneCommand) {
setState("error", undefined)
const owner = session.ownership.capture()
void state.registration?.command(command).catch((error: unknown) => {
if (!owner.current()) return
setState("error", error instanceof Error ? error.message : language.t("common.requestFailed"))
})
},
}
}
-137
View File
@@ -1,137 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Loader } from "@opencode-ai/ui/loader"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: {
registration: BrowserPaneRegistration
browser: ReturnType<typeof createSessionBrowser>
visible: boolean
}) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const state = props.browser.state
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
const covered = (rect: DOMRect) =>
Array.from(document.querySelectorAll('[data-popper-positioner]:not(:has([role="tooltip"]))')).some((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const measure = () => {
frame = undefined
if (!surface) return
const rect = surface.getBoundingClientRect()
const zoom = platform.webviewZoom?.() ?? 1
const left = Math.round(rect.left * zoom)
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
const next = `${visible}:${left}:${top}:${right}:${bottom}`
if (next !== layout) {
layout = next
props.registration.setLayout({
visible,
bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
})
}
if (performance.now() < until) frame = requestAnimationFrame(measure)
}
const schedule = (duration = 0) => {
until = Math.max(until, performance.now() + duration)
if (frame === undefined) frame = requestAnimationFrame(measure)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(
on([() => platform.webviewZoom?.(), () => dialog.active, () => store.visible, () => props.visible], () =>
schedule(300),
),
)
createResizeObserver(() => surface, schedule.bind(null, 0))
createEventListener(window, "resize", () => schedule(300))
// Floating content portals directly into <body>; keep measuring briefly so
// the positioner has settled before the overlap check runs.
const portals = new MutationObserver(() => schedule(300))
portals.observe(document.body, { childList: true })
onCleanup(() => portals.disconnect())
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
props.registration.setLayout()
})
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => props.browser.command({ type: direction })}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => props.browser.command(state()?.loading ? { type: "stop" } : { type: "reload" })}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
}
/>
<form
class="min-w-0 flex-1"
onSubmit={(event) => {
event.preventDefault()
if (store.address.trim()) props.browser.command({ type: "navigate", url: store.address })
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onInput={(event) => setStore("address", event.currentTarget.value)}
/>
</form>
</div>
<Show when={props.browser.error()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
</aside>
)
}
@@ -12,7 +12,6 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Menu } from "@opencode-ai/ui/menu"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import FileTree from "@/session/files/file-tree"
@@ -21,8 +20,6 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
const reviewTabID = "session-side-panel-review-tab"
const reviewTabPanelID = "session-side-panel-review-tabpanel"
const browserTabID = "session-side-panel-browser-tab"
const browserTabPanelID = "session-side-panel-browser-tabpanel"
const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
import { SessionContextTab } from "@/session/files/session-context-tab"
import { SortableTab } from "@/session/files/tab"
@@ -36,7 +33,6 @@ import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
SESSION_BROWSER_TAB,
createOpenSessionFileTab,
createSessionTabs,
shouldShowFileTree,
@@ -45,8 +41,6 @@ import {
import { setSessionHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab"
import { SessionBrowserPane } from "@/session/browser/pane"
import type { createSessionBrowser } from "@/session/browser/model"
type ReviewDiff = FileDiffInfo
type RenderDiff = FileDiffInfo
@@ -71,7 +65,6 @@ export function SessionSidePanel(props: {
reviewPresent?: boolean
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
}) {
const layout = useLayout()
const settings = useSettings()
@@ -172,7 +165,6 @@ export function SessionSidePanel(props: {
review: reviewTab,
hasReview: () => props.canReview,
fileBrowser: () => true,
browser: props.browser.opened,
})
const contextOpen = tabState.contextOpen
const openFileOpen = tabState.openFileOpen
@@ -210,7 +202,7 @@ export function SessionSidePanel(props: {
openReviewPanel()
tabs().setActive(next)
}
const fileTab = createMemo(() => {
const browserTab = createMemo(() => {
const active = activeTab()
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
if (active && file.pathFromTab(active)) return active
@@ -220,11 +212,11 @@ export function SessionSidePanel(props: {
// selects Review while the tab For replaces a preview trigger, which would
// otherwise dispose the sidebar and reset scroll.
const fileBrowserMounted = createMemo(() => {
return openedTabs().length > 0 || openFileOpen() || !!fileTab()
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active !== "review" && active !== "context" && active !== "empty" && active !== SESSION_BROWSER_TAB
return active !== "review" && active !== "context" && active !== "empty"
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
@@ -306,7 +298,7 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky left-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
<Show when={reviewTab() && props.canReview}>
@@ -411,72 +403,31 @@ export function SessionSidePanel(props: {
</Show>
)}
</For>
<Show when={props.browser.opened()}>
<Tabs.Trigger
value={SESSION_BROWSER_TAB}
id={browserTabID}
aria-controls={activeTab() === SESSION_BROWSER_TAB ? browserTabPanelID : undefined}
onMiddleClick={props.browser.close}
closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom" gutter={10}>
<Tabs.CloseButton
onClick={props.browser.close}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
<div class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip
value={
<>
{language.t("command.file.open")}
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
</>
}
hideCloseButton
placement="bottom"
class="flex items-center"
>
<div class="flex items-center gap-1.5">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Tabs.Trigger>
</Show>
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<Menu appearance="standard" modal={false} placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
aria-label={language.t("session.tab.add")}
// The tablist redirects focus entering it to the selected
// tab, which counts as focus-outside and closes the menu.
onPointerDown={(event: PointerEvent) => event.preventDefault()}
/>
<Menu.Portal>
<Menu.Content>
<Menu.Item
onSelect={openFileBrowser}
shortcut={
<Show when={openFileKeybind().length > 0}>
<Keybind keys={openFileKeybind()} variant="neutral" />
</Show>
}
>
<div class="flex items-center gap-2">
<Icon name="open-file" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
<Show when={props.browser.available()}>
<Menu.Item onSelect={props.browser.open}>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
<IconButton
icon={<Icon name="plus-small" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
aria-label={language.t("command.file.open")}
/>
</Tooltip>
</div>
</Tabs.List>
<div
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pe-3"
class="session-review-v2-open-in-app-slot shrink-0 flex items-center pr-3"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
@@ -518,28 +469,6 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
role="tabpanel"
aria-labelledby={browserTabID}
data-slot="tabs-content"
class="h-full min-h-0 overflow-hidden"
classList={{ hidden: activeTab() !== SESSION_BROWSER_TAB }}
inert={activeTab() !== SESSION_BROWSER_TAB || undefined}
>
<Show when={props.browser.registration()} keyed>
{(registration) => (
<SessionBrowserPane
registration={registration}
browser={props.browser}
visible={activeTab() === SESSION_BROWSER_TAB}
/>
)}
</Show>
</div>
</Show>
<Show when={fileBrowserMounted()}>
<div
id={fileBrowserTabPanelID}
@@ -550,11 +479,11 @@ export function SessionSidePanel(props: {
inert={!fileBrowserVisible() || undefined}
>
<SessionFileBrowserTab
tab={fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
tab={browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB}
placeholder={
(fileTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
(browserTab() ?? activeFileTab() ?? SESSION_OPEN_FILE_TAB) === SESSION_OPEN_FILE_TAB
}
active={file.pathFromTab(fileTab() ?? activeFileTab() ?? "")}
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
kinds={kinds()}
state={props.fileBrowserState}
onSelect={(path) => previewTab(file.tab(path))}
-19
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BROWSER_TAB,
SESSION_OPEN_FILE_TAB,
createOpenReviewFile,
createOpenSessionFileTab,
@@ -234,22 +233,4 @@ describe("createSessionTabs", () => {
dispose()
})
})
test("exposes one browser tab without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
browser: () => true,
})
expect(result.panelTabs()).toEqual([])
expect(result.activeTab()).toBe(SESSION_BROWSER_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_BROWSER_TAB)
dispose()
})
})
})
+5 -9
View File
@@ -2,9 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/runtime/persistence/equality"
import { SESSION_BROWSER_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export { SESSION_BROWSER_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export { SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
const emptyTabs: string[] = []
@@ -20,7 +20,6 @@ type TabsInput = {
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fileBrowser?: Accessor<boolean>
browser?: Accessor<boolean>
}
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
@@ -31,7 +30,6 @@ export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fileBrowser = input.fileBrowser ?? (() => false)
const browser = input.browser ?? (() => false)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openFileOpen = createMemo(
() =>
@@ -45,7 +43,7 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review" || tab === SESSION_BROWSER_TAB) return []
if (tab === "context" || tab === "review") return []
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
@@ -63,7 +61,6 @@ export const createSessionTabs = (input: TabsInput) => {
const active = input.tabs().active()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active === SESSION_BROWSER_TAB && browser()) return active
if (active === "review" && review()) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
@@ -73,16 +70,15 @@ export const createSessionTabs = (input: TabsInput) => {
if (review() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo<string | undefined>(() => {
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const closableTab = createMemo<string | undefined>(() => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active === SESSION_BROWSER_TAB && browser()) return active
if (!openedTabs().includes(active)) return
return active
})
+1 -7
View File
@@ -13,7 +13,6 @@ import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
const StatusDrawer = lazy(async () => {
const { StatusDrawer } = await import("@/shell/status/status-drawer")
@@ -145,11 +144,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
)
}
export function SessionDesktopReview(props: {
review: SessionReviewModel
browser: ReturnType<typeof createSessionBrowser>
present?: boolean
}) {
export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) {
return (
<Suspense>
<SessionSidePanel
@@ -173,7 +168,6 @@ export function SessionDesktopReview(props: {
reviewPresent={props.present}
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
/>
</Suspense>
)
+1 -3
View File
@@ -29,7 +29,6 @@ import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -44,7 +43,6 @@ export function SessionScreen(props: { session: SessionModel }) {
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
@@ -356,7 +354,7 @@ export function SessionScreen(props: { session: SessionModel }) {
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
<SessionDesktopReview review={review} present={store.sideReviewPresent} />
</div>
</Show>
</div>
@@ -438,20 +438,6 @@ export const SettingsGeneral: Component<{
<h3 class="settings-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<Show when={platform.browserPane}>
<SettingsRow
title={language.t("settings.general.row.browserPane.title")}
description={language.t("settings.general.row.browserPane.description")}
>
<div data-action="settings-experimental-browser">
<Switch
checked={settings.general.experimentalBrowser()}
onChange={(checked) => settings.general.setExperimentalBrowser(checked)}
/>
</div>
</SettingsRow>
</Show>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
-1
View File
@@ -100,7 +100,6 @@ describe("settings schema", () => {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: true,
},
appearance: {
fontSize: 14,
-9
View File
@@ -87,7 +87,6 @@ const generalSchema = Persistence.struct({
mobileDiffWrap: Schema.Boolean,
terminalPlacement: Schema.Literals(["side", "bottom"]),
followUpBehavior: Schema.Literals(["queue", "steer"]),
experimentalBrowser: Schema.Boolean,
})
const appearanceSchema = Persistence.struct({
@@ -180,7 +179,6 @@ export const defaultSettings: Settings = {
mobileDiffWrap: true,
terminalPlacement: "side",
followUpBehavior: "steer",
experimentalBrowser: true,
},
appearance: { fontSize: 14, mono: "", sans: "", terminal: "", tabLayout: "horizontal", showProjectName: false },
keybinds: {},
@@ -302,13 +300,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowUpBehavior(value: FollowUpBehavior) {
setStore("general", "followUpBehavior", value)
},
experimentalBrowser: withFallback(
() => store.general?.experimentalBrowser,
defaultSettings.general.experimentalBrowser,
),
setExperimentalBrowser(value: boolean) {
setStore("general", "experimentalBrowser", value)
},
},
visibility: {
fileTree: showFileTree,
@@ -1,5 +1,4 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export type SessionTabs = {
active?: string
@@ -89,7 +89,12 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -5,7 +5,6 @@ import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
@@ -17,9 +16,9 @@ export default Runtime.handler(
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package)
const entrypoints = Host.resolve(installed)
const target = configurationTarget(entrypoints.server, entrypoints.tui)
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target)
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
@@ -9,9 +9,8 @@ import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { fileURLToPath } from "node:url"
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -23,27 +22,27 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) {
const directory = fileURLToPath(local)
const entrypoints = Host.resolve({ directory })
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
}
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target)
if (!Host.resolve(installed).tui) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
return [{ target, version: installed.version }]
}),
)
const output = format(response.data, configured.flat(), input.builtin)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
@@ -82,7 +81,9 @@ export function format(
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
@@ -26,7 +26,7 @@ export default Runtime.handler(
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
: npm.update(item.target).pipe(Effect.asVoid)
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(
Effect.exit,
Effect.map((result) => ({ item, result })),
@@ -985,6 +985,15 @@ export type ReferenceUpdated = {
data: {}
}
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
}
export type PluginUpdated = {
id: string
created: number
@@ -2290,6 +2299,7 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
+202 -55
View File
@@ -83,8 +83,10 @@ type LocationData = {
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcpServer?: McpServer[]
mcpResource?: McpResource[]
mcp?: {
server?: McpServer[]
resource?: McpResource[]
}
model?: ModelInfo[]
provider?: ProviderInfo[]
reference?: ReferenceInfo[]
@@ -123,8 +125,8 @@ export function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
function locationQuery(ref: LocationRef) {
return { directory: ref.directory, workspace: ref.workspaceID }
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
@@ -1271,41 +1273,6 @@ export function createData(config: CreateDataInput) {
}
}
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
// effective location, and publishes under the server's canonical location; `alias`
// also publishes under the requested key when the two differ.
function locationResource<Field extends keyof LocationData>(
field: Field,
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
options?: { alias?: boolean },
) {
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
return {
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
sync: (ref?: LocationRef) => {
const location = ref ?? defaultLocation()
const id = locationKey(location)
return sync.run(`location.${field}:${id}`, async () => {
const response = await load(locationQuery(location))
const key = locationKey(response.location)
publish(key, response.data)
if (options?.alias && key !== id) publish(id, response.data)
})
},
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
}
}
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
const shells = locationResource("shell", async (location) => {
const response = await api().shell.list({ location })
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
return {
location: response.location,
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
}
})
const result = {
on: config.event.on,
listen: config.event.listen,
@@ -1773,7 +1740,7 @@ export function createData(config: CreateDataInput) {
},
shell: {
list(location?: LocationRef) {
return Object.values(shells.list(location) ?? {})
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
@@ -1785,8 +1752,31 @@ export function createData(config: CreateDataInput) {
.map((data) => data.shell?.[id])
.find((shell) => shell !== undefined)
},
sync: shells.sync,
invalidate: shells.invalidate,
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.shell:${id}`, async () => {
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
},
},
location: {
info(ref?: LocationRef) {
@@ -1841,20 +1831,162 @@ export function createData(config: CreateDataInput) {
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
integration: locationResource("integration", (location) => api().integration.list({ location })),
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await api().vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.agent:${id}`, async () => {
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], agent: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
},
},
command: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.command
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.command:${id}`, async () => {
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], command: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
},
},
integration: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.integration:${id}`, async () => {
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], integration: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
},
},
mcp: {
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
resource: locationResource("mcpResource", async (location) => {
const response = await api().mcp.resource.catalog({ location })
return { location: response.location, data: response.data.resources }
}),
server: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.server:${id}`, async () => {
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, server: response.data },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
},
},
resource: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.resource:${id}`, async () => {
const response = await api().mcp.resource.catalog({
location: locationQuery(ref ?? defaultLocation()),
})
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
},
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.model:${id}`, async () => {
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], model: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
},
},
provider: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.provider:${id}`, async () => {
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], provider: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.reference:${id}`, async () => {
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], reference: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
},
},
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
reference: locationResource("reference", (location) => api().reference.list({ location })),
websearch: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearch
@@ -1869,7 +2001,22 @@ export function createData(config: CreateDataInput) {
})
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.skill:${id}`, async () => {
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], skill: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
},
},
},
}
@@ -20378,6 +20378,44 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.added": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["plugin.added"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.updated": {
"type": "object",
"properties": {
@@ -23085,6 +23123,9 @@
{
"$ref": "#/components/schemas/permission.v2.replied"
},
{
"$ref": "#/components/schemas/plugin.added"
},
{
"$ref": "#/components/schemas/plugin.updated"
},
-2
View File
@@ -20,7 +20,6 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun run script/test.ts",
@@ -122,7 +121,6 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
@@ -1,210 +0,0 @@
// Measures the heap retained by Location service graphs and by the models.dev
// catalog plugin. Everything runs against a temporary global directory with a
// temporary home, an in-memory database, no filesystem watchers, and no network,
// so it never touches a live server, database, or user configuration.
//
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
//
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
// highest heapUsed sampled without forcing GC and are reported separately.
import fs from "fs/promises"
import os from "os"
import path from "path"
import { heapStats } from "bun:jsc"
import { Effect, Layer, Logger, Scope } from "effect"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Bus } from "../src/bus"
import { Catalog } from "../src/catalog"
import { Database } from "../src/database/database"
import { Integration } from "../src/integration"
import { Location } from "../src/location"
import { LocationServiceMap } from "../src/location-service-map"
import { ModelsDev } from "../src/models-dev"
import { Plugin } from "../src/plugin"
import { ModelsDevPlugin } from "../src/plugin/models-dev"
import { AbsolutePath } from "../src/schema"
import { Watcher } from "../src/filesystem/watcher"
import { location } from "../test/fixture/location"
import { catalogHost, host, integrationHost } from "../test/plugin/host"
const args = process.argv.slice(2)
const flag = (name: string, fallback: number) => {
const index = args.indexOf(`--${name}`)
if (index === -1) return fallback
const value = Number(args[index + 1])
if (!Number.isInteger(value) || value < 1) {
console.error(`--${name} must be a positive integer`)
process.exit(1)
}
return value
}
const locationCount = flag("locations", 6)
const pluginCount = flag("plugins", 8)
const jsonIndex = args.indexOf("--json")
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
type Sample = { heapUsed: number; rss: number; objects: number }
const sample = (): Sample => {
Bun.gc(true)
Bun.gc(true)
const usage = process.memoryUsage()
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
}
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
const median = (values: ReadonlyArray<number>) => {
const sorted = values.toSorted((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
const upper = sorted[middle] ?? 0
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
}
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
const globalLayer = Global.layerWith({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "cache", "bin"),
log: path.join(root, "data", "log"),
repos: path.join(root, "data", "repos"),
})
const replacements = [
Global.node.replace(globalLayer),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
]
// One full Location graph per directory, retained for the rest of the run, the
// way a long-running server retains every directory a client has touched.
const locationsProgram = Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
const rss: number[] = []
let previous = before
for (let index = 0; index < locationCount; index++) {
const directory = path.join(root, "projects", `location-${index}`)
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const context = yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
.pipe(Scope.provide(scope))
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
yield* plugins.awaitActivation
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
rss.push(current.rss)
previous = current
}
const catalog = yield* Catalog.Service.pipe(
Effect.provideContext(
yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
.pipe(Scope.provide(scope)),
),
)
const models = yield* catalog.model.all()
const providers = yield* catalog.provider.all()
return {
before,
after: previous,
deltas,
rss,
catalog: { providers: providers.length, models: models.length },
}
}).pipe(Effect.scoped)
// The models.dev plugin alone, against a real Catalog and Integration state per
// instance, isolates the catalog-copy contribution from the rest of the graph.
const pluginProgram = Effect.gen(function* () {
const modelsDev = yield* ModelsDev.Service
const snapshot = yield* modelsDev.get()
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
let previous = before
for (let index = 0; index < pluginCount; index++) {
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location(Location.Ref.make({ directory }))),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
Location.node.replace(locationLayer),
...replacements,
]),
).pipe(Scope.provide(scope))
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
yield* ModelsDevPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
yield* catalog.model.all()
yield* integration.list()
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
previous = current
}
return {
before,
after: previous,
deltas,
snapshot: {
providers: snapshot.length,
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
},
}
}).pipe(Effect.scoped)
const program = Effect.gen(function* () {
const plugin = yield* pluginProgram.pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
)
const locations = yield* locationsProgram.pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
),
)
return { plugin, locations }
}).pipe(Effect.provide(Logger.layer([])))
const result = await Effect.runPromise(program)
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
console.log(
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
)
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
console.log(
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
)
console.log(
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
)
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
console.log(
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
)
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
if (jsonPath) {
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
await fs.writeFile(
jsonPath,
JSON.stringify(
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
null,
2,
),
)
}
+19 -39
View File
@@ -3,11 +3,10 @@ export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Host } from "@opencode-ai/plugin/host"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { fileURLToPath } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
@@ -42,8 +41,8 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugins can live outside config roots, where the
// config change feed cannot see them; watch those targets directly.
// Configured local plugin entrypoints can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
@@ -57,10 +56,7 @@ export const layer = Layer.effect(
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({
path: operation.target,
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
})
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
@@ -152,40 +148,24 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
return Option.some<Operation>(operation)
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
if (!(yield* fs.existsSafe(operation.target))) return [operation]
const directory = yield* fs.isDir(operation.target)
const entrypoints: Host.Entrypoints = directory
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
: { server: pathToFileURL(operation.target).href }
if (!entrypoints.server) return []
if (directory) {
const root = yield* fs.resolve(operation.target)
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
if (!FSUtil.contains(root, server)) return []
}
const times = yield* Effect.forEach(
[
...Object.values(entrypoints)
.filter((entry) => entry !== undefined)
.map((entry) => fileURLToPath(entry)),
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
],
(entry) =>
fs.stat(entry).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return [{ ...operation, mtime: Math.max(...times) }]
}),
).pipe(Effect.map((operations) => operations.flat()))
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
})
})
function isPluginSource(entries: readonly Entry[], file: string) {
+1
View File
@@ -52,6 +52,7 @@ const layer = Layer.effect(
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
-2
View File
@@ -77,7 +77,6 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -189,7 +188,6 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
+5 -8
View File
@@ -10,8 +10,6 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
// The normalized snapshot is shared by every Location and only read here; the catalog
// receives copies below, so retaining a second copy per Location is unnecessary.
const loaded = { data: snapshots(yield* modelsDev.get()) }
yield* ctx.integration.transform((integrations) => {
for (const provider of loaded.data) {
@@ -34,7 +32,7 @@ export const ModelsDevPlugin = define({
yield* ctx.catalog.transform((catalog) => {
for (const provider of loaded.data) {
catalog.provider.update(provider.info.id, (draft) => {
Object.assign(draft, copy(provider.info))
Object.assign(draft, provider.info)
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
@@ -67,16 +65,15 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return data.filter(
return copy(data).filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
// The catalog owns and mutates its provider and model records in place, so every rebuild
// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain
// JSON, and a direct copy is an order of magnitude faster than structuredClone's general
// graph walk on the startup path.
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
// magnitude faster than structuredClone's general graph walk on the startup path.
function copy<T>(value: T): T {
if (Array.isArray(value)) return value.map(copy) as T
if (value !== null && typeof value === "object") {
+43 -24
View File
@@ -1,12 +1,12 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Host } from "@opencode-ai/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { stat } from "node:fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
@@ -35,27 +35,21 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const local = path.isAbsolute(operation.target)
const npm = yield* Npm.Service
const installed = local
? undefined
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target)
: yield* npm.add(operation.target)
// Legacy auto-discovery still admits standalone server sources. Configured
// local plugins always arrive here as directories.
const entrypoints: Host.Entrypoints =
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
? { server: pathToFileURL(operation.target).href }
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
const entrypoint = entrypoints.server
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => Host.load(source))
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
Effect.mapError(
(cause) =>
@@ -66,20 +60,45 @@ export const load = Effect.fn("PluginModule.load")(function* (
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
features: {
...(entrypoints.tui ? { tui: true as const } : {}),
...(entrypoints.rpc ? { rpc: true as const } : {}),
},
revision: JSON.stringify([operation, installed?.revision]),
features,
revision: JSON.stringify([operation, installed.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: fileURLToPath(entrypoint) }
? { type: "local" as const, path: operation.target }
: {
type: "package" as const,
target: operation.target,
...(installed?.version ? { version: installed.version } : {}),
...(installed.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { Permission } from "../permission.js"
const plan = Agent.ID.make("plan")
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
You are in Plan mode. You may optionally create or update plan documents in:
${directory}
Do not modify any other files or ask a subagent to do so.
@@ -1,16 +1,17 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { Npm } from "@opencode-ai/util/npm"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
import { importModule } from "@opencode-ai/util/runtime-import"
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
const installedPath = packageName.startsWith("file://")
? packageName
: yield* npm.add(packageName).pipe(
Effect.orDie,
Effect.map((installed) => resolveModule(installed.name, installed.directory)),
)
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
const mod = (yield* Effect.promise(() => importModule(installedPath))) as Record<string, unknown>
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
return mod[match]
+21 -2
View File
@@ -22,12 +22,31 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return Option.some(entry.target)
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
export function entrypoint(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
return yield* Effect.findFirst(
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
+2 -1
View File
@@ -95,7 +95,8 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (specifie
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () => resolveModule(specifier, installed.directory),
try: () =>
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
+19 -12
View File
@@ -117,11 +117,29 @@ const layer = Layer.effect(
const parsed = yield* parse(method.input, input).pipe(
Effect.mapError((error) => failure("rpc.invalid_input", errorMessage(error, "Invalid RPC input"))),
)
// Handler defects and undeclared errors become the same typed rpc.internal failure the HTTP handler
// exposes, so an in-process plugin caller can recover exactly like a remote one.
const internal = (error: unknown, message = "RPC call failed") =>
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, error }).pipe(
Effect.andThen(Effect.fail(failure("rpc.internal", message))),
)
const result = yield* Effect.suspend(() => {
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(Effect.catch((error) => encodeError(method, error)))
}).pipe(
Effect.catch((error) => {
if (!(error instanceof DeclaredError)) return internal(error)
const declared = method.errors && Object.hasOwn(method.errors, error.type) && method.errors[error.type]
if (!declared) return internal(error, `Undeclared RPC error: ${error.type}`)
return encode(declared, error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}),
// Also covers declared error data that fails its own schema: a handler bug, not a caller error.
Effect.catchDefect((defect) => internal(defect)),
)
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
@@ -215,17 +233,6 @@ function encode(schema: Tool.ValueSchema, value: unknown): Effect.Effect<unknown
return Schema.isSchema(schema) ? Schema.encodeUnknownEffect(schema)(value) : parse(schema, value)
}
function encodeError(method: Rpc.Method, error: unknown): Effect.Effect<never, Rpc.Failure> {
if (!(error instanceof DeclaredError)) return Effect.die(error)
if (!method.errors || !Object.hasOwn(method.errors, error.type)) {
return Effect.die(new Error(`Undeclared RPC error: ${error.type}`))
}
return encode(method.errors[error.type], error.data).pipe(
Effect.catch((cause) => Effect.die(cause)),
Effect.flatMap((data) => Effect.fail(failure(error.type, error.message, data))),
)
}
function decodeError(method: Rpc.Method, error: Rpc.Failure): Effect.Effect<never, Rpc.Failure> {
if (!method.errors || !Object.hasOwn(method.errors, error.type)) return Effect.fail(error)
return read(method.errors[error.type], error.data).pipe(
+31 -39
View File
@@ -162,7 +162,7 @@ describe("Npm.cacheKey", () => {
})
describe("Npm.add", () => {
test("locates cached scoped package specs without reifying", async () => {
test("resolves cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
const spec = "@fixture/provider@1.0.0"
const directory = path.join(
@@ -185,7 +185,24 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toBe(directory)
expect(entry.name).toBe("@fixture/provider")
expect(entry.entrypoint).toEndWith("/index.js")
})
test("falls back to the original spec when parsing fails", async () => {
await using tmp = await tmpdir()
const spec = "fixture provider"
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, { name: spec, exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
})
test("reifies when package cache directory exists without the package installed", async () => {
@@ -207,16 +224,16 @@ describe("Npm.add", () => {
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
expect(entries.cached).toEqual(entries.added)
expect(entries.tui.entrypoint).toEndWith("/tui.js")
expect(entries.fallback.entrypoint).toEndWith("/index.js")
})
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec =
@@ -233,7 +250,7 @@ describe("Npm.add", () => {
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
@@ -254,7 +271,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.name).toBe("fixture-subdirectory-plugin")
expect(entry.entrypoint).toEndWith("/index.js")
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
@@ -302,44 +319,19 @@ describe("Npm.add", () => {
expect(result.pinned).toContain("root: true")
expect(result.current).toBeFalse()
}, 30_000)
// Symlink creation needs elevated privileges on Windows.
test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
await fs.mkdir(path.join(tmp.path, "cache"))
await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link"))
const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch`
const result = await Effect.gen(function* () {
const npm = yield* Npm.Service
const added = yield* npm.add(mutable)
const current = yield* npm.check(mutable)
yield* Effect.promise(async () => {
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
await Bun.$`git -C ${fixture.repository} add .`
await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second`
})
return { added, current, outdated: yield* npm.check(mutable) }
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise)
expect(result.added.version).toBe(fixture.commit)
expect(result.current).toBeFalse()
expect(result.outdated).toBeTrue()
}, 30_000)
})
describe("Npm.resolve", () => {
test("locates a cached package without installing it", async () => {
test("resolves a TUI entrypoint only when the package is already cached", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec)
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(missing.version).toBeUndefined()
expect(missing.entrypoint).toBeUndefined()
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, {
@@ -351,9 +343,9 @@ describe("Npm.resolve", () => {
const resolved = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec)
return yield* npm.resolve(spec, { subpaths: ["tui"] })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.directory).toBe(directory)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.version).toBe("1.0.0")
})
})
+3 -3
View File
@@ -39,10 +39,10 @@ import { emptyMcpLayer } from "../fixture/mcp"
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: (name) => Effect.succeed({ directory: "", name }),
resolve: (name) => Effect.succeed({ directory: "", name }),
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: (name) => Effect.succeed({ directory: "", name }),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
}),
)
+1 -310
View File
@@ -1,8 +1,7 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Context, Effect, Exit, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { Effect, Exit, Layer, Scope } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -32,245 +31,10 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const isolated = testEffect(Layer.empty)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
// One complete Location graph behind the production plugin host. Two of these stand in for
// two Locations that share a single models.dev snapshot instance.
const owner = Effect.gen(function* () {
const context = yield* Layer.build(PluginTestLayer)
const host = yield* PluginHost.make(Context.get(context, Plugin.Service)).pipe(Effect.provideContext(context))
return {
context,
host,
bus: Context.get(context, Bus.Service),
catalog: Context.get(context, Catalog.Service),
integration: Context.get(context, Integration.Service),
}
})
// Every nested overlay shape the normalized snapshot can carry, so in-place mutation of any
// existing nested value is observable on the source object.
const richSnapshot = (name = "Acme") => {
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const snapshot = [
{
info: {
id: providerID,
name,
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } },
headers: { "x-acme": "provider" },
body: { service_tier: "default", tags: ["stable"] },
},
environment: ["ACME_API_KEY", "ACME_HOST"],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
family: Model.Family.make("gpt"),
settings: { baseURL: "https://models.acme.test/v1", reasoning: { effort: "low" } },
headers: { "x-mode": "fast" },
body: { service_tier: "priority", options: { top_k: 1 }, stop: ["<end>"] },
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [
{
id: Model.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
body: { max_tokens: 1024 },
},
],
time: { released: Date.parse("2026-01-01") },
cost: [
{
input: Money.USDPerMillionTokens.make(2.5),
output: Money.USDPerMillionTokens.make(15),
cache: { read: Money.USDPerMillionTokens.make(0.25), write: Money.USDPerMillionTokens.zero },
},
{
tier: { type: "context", size: 200_000 },
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache: { read: Money.USDPerMillionTokens.make(0.5), write: Money.USDPerMillionTokens.zero },
},
],
status: "active",
enabled: true,
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
return { providerID, modelID, snapshot }
}
describe("ModelsDevPlugin", () => {
isolated.effect("shares one snapshot between Locations while each catalog mutates only its own copies", () =>
Effect.gen(function* () {
const { providerID, modelID, snapshot } = richSnapshot()
const pristine = JSON.stringify(snapshot)
const source = ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void })
const first = yield* owner
const second = yield* owner
for (const each of [first, second])
yield* ModelsDevPlugin.effect(each.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(each.context),
)
// The stored environment method must own its names array; the source array is shared.
let names: readonly string[] | undefined
yield* first.integration.transform((draft) => {
names = draft.method
.list(Integration.ID.make(providerID))
.flatMap((method) => (method.type === "env" ? [method.names] : []))[0]
})
expect(names).toEqual(snapshot[0].environment)
expect(names).not.toBe(snapshot[0].environment)
// Catalog-owned provider records are copies, not the source's nested objects.
const stored = required(yield* first.catalog.provider.get(providerID))
expect(stored.settings).toEqual(snapshot[0].info.settings)
expect(stored.settings).not.toBe(snapshot[0].info.settings)
expect(stored.headers).not.toBe(snapshot[0].info.headers)
expect(stored.body).not.toBe(snapshot[0].info.body)
// A later plugin in the first Location mutates existing nested values in place through the
// production host, the way the Bedrock, Azure, and config provider plugins do.
const scope = yield* Scope.make()
yield* first.host.catalog
.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
required(provider.settings).baseURL = "https://override.acme.test/v1"
required(provider.settings).thinking.type = "disabled"
required(provider.headers)["x-acme"] = "override"
required(provider.body).service_tier = "priority"
required(provider.body).tags.push("override")
})
catalog.model.update(providerID, modelID, (model) => {
required(model.settings).reasoning.effort = "high"
required(model.headers)["x-mode"] = "slow"
required(model.body).options.top_k = 7
required(model.body).stop.push("<stop>")
const variant = required(model.variants[0])
required(variant.settings).thinking.type = "disabled"
required(variant.body).max_tokens = 4096
model.capabilities.input.push("image")
required(model.cost[0]).cache.read = Money.USDPerMillionTokens.make(9)
required(required(model.cost[1]).tier).size = 1
required(model.cost[1]).input = Money.USDPerMillionTokens.make(42)
model.limit.context = 1
model.time.released = 5
})
})
.pipe(Scope.provide(scope))
const mutatedProvider = required(yield* first.catalog.provider.get(providerID))
const mutated = required(yield* first.catalog.model.get(providerID, modelID))
expect(mutatedProvider.settings).toEqual({
baseURL: "https://override.acme.test/v1",
thinking: { type: "disabled", display: "summarized" },
})
expect(mutatedProvider.headers).toEqual({ "x-acme": "override" })
expect(mutatedProvider.body).toEqual({ service_tier: "priority", tags: ["stable", "override"] })
expect(mutated.settings).toEqual({
baseURL: "https://models.acme.test/v1",
thinking: { type: "disabled", display: "summarized" },
reasoning: { effort: "high" },
})
expect(mutated.headers).toEqual({ "x-acme": "override", "x-mode": "slow" })
expect(mutated.body).toEqual({
service_tier: "priority",
tags: ["stable", "override"],
options: { top_k: 7 },
stop: ["<end>", "<stop>"],
})
expect(mutated.variants).toEqual([
{
id: Model.VariantID.make("low"),
settings: { thinking: { type: "disabled", display: "summarized" }, effort: "low" },
body: { max_tokens: 4096 },
},
])
expect(mutated.capabilities.input).toEqual(["text", "image"])
expect(mutated.cost[0]?.cache.read).toBe(Money.USDPerMillionTokens.make(9))
expect(mutated.cost[1]).toMatchObject({ tier: { type: "context", size: 1 }, input: 42 })
expect(mutated.limit.context).toBe(1)
expect(mutated.time.released).toBe(5)
// The sibling Location and the shared source are untouched.
const sibling = required(yield* second.catalog.model.get(providerID, modelID))
expect(sibling.settings).toEqual({
baseURL: "https://models.acme.test/v1",
thinking: { type: "adaptive", display: "summarized" },
reasoning: { effort: "low" },
})
expect(sibling.headers).toEqual({ "x-acme": "provider", "x-mode": "fast" })
expect(sibling.body).toEqual({
service_tier: "priority",
tags: ["stable"],
options: { top_k: 1 },
stop: ["<end>"],
})
expect(sibling.variants).toEqual(snapshot[0].models[0].variants)
expect(sibling.capabilities.input).toEqual(["text"])
expect(sibling.cost).toEqual(snapshot[0].models[0].cost)
expect(sibling.limit).toEqual(snapshot[0].models[0].limit)
expect(sibling.time).toEqual(snapshot[0].models[0].time)
expect(required(yield* second.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
expect(JSON.stringify(snapshot)).toBe(pristine)
// Removing the mutating transform rebuilds the first catalog from the shared source.
yield* Scope.close(scope, Exit.void)
expect(yield* first.catalog.model.get(providerID, modelID)).toEqual(sibling)
expect(required(yield* first.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
expect(JSON.stringify(snapshot)).toBe(pristine)
}),
)
isolated.effect("replaces its snapshot reference on refresh without touching the sibling Location", () =>
Effect.gen(function* () {
const initial = richSnapshot("Acme")
const refreshed = richSnapshot("Acme Refreshed")
const pristine = JSON.stringify(initial.snapshot)
const current = { snapshot: initial.snapshot }
const source = ModelsDev.Service.of({
get: () => Effect.sync(() => current.snapshot),
refresh: () => Effect.void,
})
const first = yield* owner
const second = yield* owner
for (const each of [first, second])
yield* ModelsDevPlugin.effect(each.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(each.context),
)
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme")
current.snapshot = refreshed.snapshot
yield* first.bus.publish(ModelsDev.Event.Refreshed, {})
// Integration and catalog reloads are debounced sequentially.
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme Refreshed")
expect(required(yield* second.catalog.provider.get(initial.providerID)).name).toBe("Acme")
expect(JSON.stringify(initial.snapshot)).toBe(pristine)
expect(JSON.stringify(refreshed.snapshot)).toBe(JSON.stringify(richSnapshot("Acme Refreshed").snapshot))
}),
)
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -328,79 +92,6 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("keeps the shared models.dev snapshot pristine while catalog transforms mutate records in place", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const snapshot = [
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
headers: { "x-acme": "provider" },
},
environment: ["ACME_API_KEY"],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
settings: { baseURL: "https://models.acme.test/v1" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 1_050_000, output: 128_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
const pristine = JSON.stringify(snapshot)
// The plugin receives the same snapshot instance every Location shares.
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void }),
),
)
// Later plugins mutate nested provider and model records in place, as the Bedrock provider does.
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
if (provider.settings) provider.settings.baseURL = "https://override.acme.test/v1"
if (provider.headers) provider.headers["x-acme"] = "override"
})
draft.model.update(providerID, modelID, (model) => {
if (model.settings) model.settings.baseURL = "https://override.models.acme.test/v1"
model.variants.push({ id: Model.VariantID.make("configured") })
model.capabilities.input.push("image")
})
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(provider?.settings?.baseURL).toBe("https://override.acme.test/v1")
expect(provider?.headers).toEqual({ "x-acme": "override" })
expect(model?.settings?.baseURL).toBe("https://override.models.acme.test/v1")
expect(model?.variants).toEqual([{ id: Model.VariantID.make("configured") }])
expect(model?.capabilities.input).toEqual(["text", "image"])
expect(JSON.stringify(snapshot)).toBe(pristine)
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+1 -1
View File
@@ -166,7 +166,7 @@ describe("plan plugin reminders", () => {
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
yield* settle(persisted, 2)
expect(persisted[0]).toContain("You are in Plan mode")
expect(persisted[0]).toContain("Do not create or update plan files unless the user explicitly asks you to")
expect(persisted[0]).toContain("optionally create or update plan documents")
expect(persisted[0]).toContain(planDirectory)
expect(persisted[0]).toContain("Do not modify any other files")
expect(persisted[1]).toContain("NO LONGER in Plan mode")
@@ -4,6 +4,7 @@ import { Cause, Effect, Layer } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Model } from "@opencode-ai/core/model"
@@ -15,15 +16,16 @@ import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
function npmPackage(directory = "") {
function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({
add: (name) => Effect.succeed({ directory, name }),
resolve: (name) => Effect.succeed({ directory, name }),
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
check: () => Effect.succeed(false),
update: (name) => Effect.succeed({ directory, name }),
update: () => Effect.succeed({ directory: "", entrypoint }),
which: () => Effect.undefined,
})
}
@@ -34,16 +36,13 @@ const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
})
function tempPackage(source: string) {
function tempEntrypoint(source: string) {
return Effect.acquireRelease(
Effect.promise(async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
await Bun.write(path.join(directory, "provider.mjs"), source)
await Bun.write(
path.join(directory, "package.json"),
JSON.stringify({ name: "fixture-provider", exports: "./provider.mjs" }),
)
return { directory }
const entrypoint = path.join(directory, "provider.mjs")
await Bun.write(entrypoint, source)
return { directory, entrypoint }
}),
(tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
)
@@ -87,11 +86,27 @@ describe("DynamicProviderPlugin", () => {
}),
)
it.live("loads npm packages through their resolved import entrypoint", () =>
it.effect("injects the provider ID as the SDK factory name", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const tmp = yield* tempPackage(`export { createFixtureProvider } from ${JSON.stringify(fixtureProvider)}\n`)
yield* addPlugin(npmPackage(tmp.directory))
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: Provider.aisdk(fixtureProvider),
}),
package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" },
})
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
}),
)
it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("npm-provider"), Model.ID.make("test-model")),
@@ -108,7 +123,7 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmPackage())
yield* addPlugin(npmEntrypoint())
const exit = yield* aisdk
.language(
Model.Info.make({
@@ -144,8 +159,8 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const tmp = yield* tempPackage("export const notAProviderFactory = true\n")
yield* addPlugin(npmPackage(tmp.directory))
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
const exit = yield* aisdk
.language(
Model.Info.make({
@@ -14,10 +14,10 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: (name) => Effect.succeed({ directory: "", name }),
resolve: (name) => Effect.succeed({ directory: "", name }),
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
check: () => Effect.succeed(false),
update: (name) => Effect.succeed({ directory: "", name }),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
which: () => Effect.undefined,
})
@@ -0,0 +1,106 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { Rpc } from "@opencode-ai/core/rpc"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { PluginTestLayer } from "./fixture"
import { Effect, Exit, Schema } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(PluginTestLayer)
const Echo = Rpc.define({
id: "shared-echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
fail: {
input: Schema.String,
output: Schema.String,
errors: { missing: Schema.Struct({ attempts: Schema.FiniteFromString }) },
},
},
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
it.effect("Effect plugins register, call, and publish RPCs independently of plugin identity", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const events: string[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type !== "rpc.shared-echo.updated") return
expect(event.location).toEqual({ directory: location.directory })
if (typeof event.data === "object" && event.data && "text" in event.data && typeof event.data.text === "string")
events.push(event.data.text)
}),
)
yield* plugins.activate([
{
id: "implementer",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Echo, {
echo: (value) => Effect.succeed(`${value}!`),
fail: (value, context) => Effect.fail(context.error("missing", "Missing", { attempts: Number(value) })),
})
yield* registration.events.emit("updated", { text: "ready" })
}).pipe(Effect.orDie),
},
{
id: "consumer",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
expect(yield* ctx.rpc(Echo).echo("hello")).toBe("hello!")
expect(yield* ctx.rpc(Echo).fail("2").pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
}).pipe(Effect.orDie),
},
])
expect(events).toEqual(["ready"])
expect(yield* rpc.client(Echo).echo("hello")).toBe("hello!")
yield* plugins.activate([])
expect(Exit.isFailure(yield* rpc.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
yield* unsubscribe
}),
)
it.effect("failed plugin setup removes RPC overrides and restores the previous implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const rpc = yield* Rpc.Service
yield* plugins.activate([
{
id: "implementer",
revision: "1",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("original"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.asVoid, Effect.orDie),
},
])
yield* plugins.activate([
{
id: "implementer",
revision: "2",
effect: (ctx) =>
ctx.rpc
.register(Echo, {
echo: () => Effect.succeed("replacement"),
fail: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: 1 })),
})
.pipe(Effect.andThen(Effect.die(new Error("setup failed"))), Effect.orDie),
},
])
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
@@ -0,0 +1,290 @@
import { describe, expect } from "bun:test"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { define } from "@opencode-ai/plugin/promise/plugin"
import type { RpcEventPayload } from "@opencode-ai/plugin/promise/rpc"
import { Rpc } from "@opencode-ai/plugin/rpc"
import { Effect, Logger } from "effect"
import { z } from "zod"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("Promise plugin RPC", () => {
it.live("adapts calls, schema transforms, failures, and registration disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-calls",
methods: {
standard: { input: z.string().transform(Number), output: z.number().transform(String) },
ping: { input: z.undefined(), output: z.null() },
errorShapedOutput: {
input: z.undefined(),
output: z.object({ type: z.string(), message: z.string(), data: z.object({ value: z.number() }) }),
},
returned: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
thrown: {
input: z.undefined(),
output: z.null(),
errors: { rejected: z.object({ attempts: z.string().transform(Number) }) },
},
defect: { input: z.undefined(), output: z.null() },
},
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-calls-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {
standard: async (input) => {
expect(input).toBe(42)
return input + 1
},
ping: async () => null,
errorShapedOutput: async () => ({ type: "ordinary", message: "Success", data: { value: 1 } }),
returned: async (_input, context) => context.error("rejected", "returned failure", { attempts: "1" }),
thrown: async (_input, context) => {
throw context.error("rejected", "thrown failure", { attempts: "2" })
},
defect: async () => {
throw new Error("handler defect")
},
})
const client = ctx.rpc(service)
expect(await client.standard("42")).toBe("43")
expect(await client.ping()).toBeNull()
expect(await client.errorShapedOutput()).toEqual({
type: "ordinary",
message: "Success",
data: { value: 1 },
})
await expect(client.returned()).rejects.toEqual({
type: "rejected",
message: "returned failure",
data: { attempts: 1 },
})
await expect(client.thrown()).rejects.toEqual({
type: "rejected",
message: "thrown failure",
data: { attempts: 2 },
})
await expect(client.defect()).rejects.toEqual({ type: "rpc.internal", message: "RPC call failed" })
await registration.dispose()
await registration.dispose()
await expect(client.ping()).rejects.toBeDefined()
},
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("cancels only the selected call and passes its AbortSignal to Promise handlers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-cancel",
methods: { wait: { input: z.string(), output: z.string() } },
events: {},
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-cancel-plugin",
setup: async (ctx) => {
const started = Promise.withResolvers<void>()
const cancelled = Promise.withResolvers<void>()
const signals = new Map<string, AbortSignal>()
await ctx.rpc.register(service, {
wait: async (input, call) => {
signals.set(input, call.signal)
if (input === "complete") return input
started.resolve()
await new Promise<void>((resolve) => {
call.signal.addEventListener(
"abort",
() => {
cancelled.resolve()
resolve()
},
{ once: true },
)
})
return input
},
})
const client = ctx.rpc(service)
const controller = new AbortController()
const pending = client.wait("cancel", { signal: controller.signal })
const rejected = pending.then(
() => false,
() => true,
)
await started.promise
expect(await client.wait("complete")).toBe("complete")
controller.abort()
expect(await rejected).toBe(true)
await cancelled.promise
expect(signals.get("cancel")?.aborted).toBe(true)
expect(signals.get("complete")?.aborted).toBe(false)
expect(await client.wait("complete")).toBe("complete")
},
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
}),
)
it.live("awaits async callbacks and logs failures without stopping other plugin listeners", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-async-listeners",
methods: {},
events: { updated: { schema: z.object({ value: z.number() }) } },
})
const error = new Error("Expected async plugin callback failure")
const reported = Promise.withResolvers<void>()
const logger = Logger.make((entry) => {
if (Array.isArray(entry.message) && entry.message.includes(error)) reported.resolve()
})
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-async-listeners-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const third = Promise.withResolvers<void>()
const failed: number[] = []
const healthy: number[] = []
client.events.on("updated", async (event) => {
failed.push(event.data.value)
started.resolve()
await release.promise
throw error
})
client.events.on("updated", (event) => {
healthy.push(event.data.value)
if (event.data.value === 2) second.resolve()
if (event.data.value === 3) third.resolve()
})
await registration.events.emit("updated", { value: 1 })
await started.promise
await registration.events.emit("updated", { value: 2 })
await second.promise
expect(failed).toEqual([1])
release.resolve()
await reported.promise
await registration.events.emit("updated", { value: 3 })
await third.promise
expect(failed).toEqual([1])
expect(healthy).toEqual([1, 2, 3])
},
}),
)
yield* plugins
.activate([{ ...adapted, revision: "1" }])
.pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
yield* plugins.activate([])
}),
)
it.live("isolates event listeners and closes pending and idle iterators on plugin unload", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const service = Rpc.define({
id: "promise-rpc-events",
methods: {},
events: {
counted: { schema: z.object({ count: z.number() }).transform(({ count }) => ({ text: String(count) })) },
},
})
const subscriptions = Promise.withResolvers<{
pending: Promise<IteratorResult<RpcEventPayload<typeof service, "counted">>>
idle: AsyncIterator<RpcEventPayload<typeof service, "counted">>
nativeIdle: AsyncIterator<unknown>
}>()
const adapted = PluginPromise.fromPromise(
define({
id: "promise-rpc-events-plugin",
setup: async (ctx) => {
const registration = await ctx.rpc.register(service, {})
const client = ctx.rpc(service)
const first: string[] = []
const second: string[] = []
const firstSeen = Promise.withResolvers<void>()
const secondSeen = Promise.withResolvers<void>()
const nextSeen = Promise.withResolvers<void>()
const unsubscribe = client.events.on("counted", (event) => {
first.push(event.data.text)
firstSeen.resolve()
})
client.events.on("counted", (event) => {
second.push(event.data.text)
if (event.data.text === "1") secondSeen.resolve()
if (event.data.text === "2") nextSeen.resolve()
})
const controller = new AbortController()
const iterator = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
const next = iterator.next()
const idle = client.events.subscribe("counted")[Symbol.asyncIterator]()
const idleNext = idle.next()
const nativeController = new AbortController()
const native = ctx.event.subscribe({ signal: nativeController.signal })[Symbol.asyncIterator]()
const nativeNext = native.next()
const nativeIdle = ctx.event.subscribe()[Symbol.asyncIterator]()
const nativeIdleNext = nativeIdle.next()
await registration.events.emit("counted", { count: 1 })
await Promise.all([firstSeen.promise, secondSeen.promise])
const event = (await next).value
expect(event.type).toBe("rpc.promise-rpc-events.counted")
expect(event.data).toEqual({ text: "1" })
expect(typeof event.location.directory).toBe("string")
expect((await idleNext).value.data).toEqual({ text: "1" })
expect((await nativeNext).value.type).toBe("rpc.promise-rpc-events.counted")
expect((await nativeIdleNext).value.type).toBe("rpc.promise-rpc-events.counted")
nativeController.abort()
expect((await native.next()).done).toBe(true)
unsubscribe()
unsubscribe()
controller.abort()
expect((await iterator.next()).done).toBe(true)
await registration.events.emit("counted", { count: 2 })
await nextSeen.promise
expect(first).toEqual(["1"])
expect(second).toEqual(["1", "2"])
const aborted = client.events.subscribe("counted", { signal: controller.signal })[Symbol.asyncIterator]()
expect((await aborted.next()).done).toBe(true)
subscriptions.resolve({
pending: client.events.subscribe("counted")[Symbol.asyncIterator]().next(),
idle,
nativeIdle,
})
},
}),
)
yield* plugins.activate([{ ...adapted, revision: "1" }])
expect(yield* plugins.list()).toMatchObject([{ id: adapted.id, state: { status: "active" } }])
const active = yield* Effect.promise(() => subscriptions.promise)
yield* plugins.activate([])
expect((yield* Effect.promise(() => active.pending)).done).toBe(true)
expect((yield* Effect.promise(() => active.idle.next())).done).toBe(true)
expect((yield* Effect.promise(() => active.nativeIdle.next())).done).toBe(true)
}),
)
})
+438
View File
@@ -0,0 +1,438 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Rpc } from "@opencode-ai/core/rpc"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Workspace } from "@opencode-ai/core/workspace"
import type { Event } from "@opencode-ai/schema/event"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, Scope, Stream } from "effect"
import { z } from "zod"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
Location.node.replace(Layer.succeed(Location.Service, location(ref))),
]),
)
const Echo = Rpc.define({
id: "test.rpc",
methods: { echo: { input: z.string(), output: z.string() } },
events: { updated: { schema: z.object({ text: z.string() }) } },
})
describe("Rpc", () => {
it.effect("creates handles before registration and resolves on every execution", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const request = client.echo("hello")
expect(yield* request.pipe(Effect.flip)).toEqual({
type: "rpc.unavailable",
message: "RPC is unavailable: test.rpc",
})
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
expect(yield* request).toBe("hello")
yield* rpc.register(Echo, { echo: (value) => Effect.succeed(`${value}!`) })
expect(yield* request).toBe("hello!")
expect(yield* rpc.call(Echo.id, "missing", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.missing",
})
expect(yield* rpc.call(Echo.id, "toString", "hello").pipe(Effect.flip)).toEqual({
type: "rpc.method_not_found",
message: "Unknown RPC method: test.rpc.toString",
})
}),
)
it.effect("uses the latest whole registration and reveals previous implementations on disposal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const client = rpc.client(Echo)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
const second = yield* rpc.register(Echo, { echo: () => Effect.succeed("second") })
const third = yield* rpc.register(Echo, { echo: () => Effect.succeed("third") })
expect(yield* client.echo("hello")).toBe("third")
yield* second.dispose
expect(yield* client.echo("hello")).toBe("third")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* third.dispose
expect(yield* client.echo("hello")).toBe("first")
yield* first.dispose
expect(Exit.isFailure(yield* client.echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("removes registrations when their owning scope closes", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
yield* rpc.register(Echo, { echo: () => Effect.succeed("original") })
const scope = yield* Scope.make()
yield* rpc.register(Echo, { echo: () => Effect.succeed("override") }).pipe(Scope.provide(scope))
expect(yield* rpc.client(Echo).echo("hello")).toBe("override")
yield* Scope.close(scope, Exit.void)
expect(yield* rpc.client(Echo).echo("hello")).toBe("original")
}),
)
it.effect("validates inputs before running handlers and validates returned results", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const received: string[] = []
yield* rpc.register(Echo, {
echo: (value) =>
Effect.sync(() => {
received.push(value)
return value
}),
})
expect(Exit.isFailure(yield* rpc.call(Echo.id, "echo", 42).pipe(Effect.exit))).toBe(true)
expect(received).toEqual([])
const Checked = Rpc.define({
id: "checked",
methods: { echo: { input: z.string(), output: z.string().min(3) } },
events: {},
})
yield* rpc.register(Checked, { echo: () => Effect.succeed("a") })
expect(Exit.isFailure(yield* rpc.client(Checked).echo("hello").pipe(Effect.exit))).toBe(true)
}),
)
it.effect("leaves local transport values to the declared schema", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Identity = Rpc.define({
id: "identity",
methods: { echo: { input: Schema.Unknown, output: Schema.Unknown } },
events: {},
})
yield* rpc.register(Identity, { echo: Effect.succeed })
const value = new Date(0)
expect(yield* rpc.client(Identity).echo(value)).toBe(value)
}),
)
it.effect("applies Standard Schema transforms once for inputs, outputs, and events", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const counts = { input: 0, output: 0, event: 0 }
const Transformed = Rpc.define({
id: "transformed",
methods: {
count: {
input: z.string().transform((value) => {
counts.input++
return Number(value)
}),
output: z.number().transform((value) => {
counts.output++
return String(value)
}),
},
},
events: {
counted: {
schema: z.object({ count: z.number() }).transform(({ count }) => {
counts.event++
return { text: String(count) }
}),
},
},
})
const registration = yield* rpc.register(Transformed, { count: (value) => Effect.succeed(value + 1) })
const client = rpc.client(Transformed)
expect(yield* client.count("41")).toBe("42")
const events = yield* client.events
.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ text: "42" })
expect(counts).toEqual({ input: 1, output: 1, event: 1 })
}),
)
it.effect("keeps encoded dispatch and decoded local results consistent for Effect codecs", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Codec = Rpc.define({
id: "codec",
methods: { count: { input: Schema.FiniteFromString, output: Schema.FiniteFromString } },
events: { counted: { schema: Schema.Struct({ count: Schema.FiniteFromString }) } },
})
const registration = yield* rpc.register(Codec, { count: (value) => Effect.succeed(value + 1) })
expect(yield* rpc.call(Codec.id, "count", "41")).toBe("42")
expect(yield* rpc.client(Codec).count("41")).toBe(42)
const events = yield* rpc
.client(Codec)
.events.subscribe("counted")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("counted", { count: 42 })
expect((yield* Fiber.join(events))[0].data).toEqual({ count: 42 })
}),
)
it.effect("validates declared error data and decodes it for local clients", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Failing = Rpc.define({
id: "failing",
methods: {
standard: {
input: z.undefined(),
output: z.string(),
errors: { missing: z.object({ attempts: z.string().transform(Number) }) },
},
effect: {
input: Schema.Undefined,
output: Schema.String,
errors: { invalid: Schema.Struct({ count: Schema.FiniteFromString }) },
},
},
events: {},
})
yield* rpc.register(Failing, {
standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
})
expect(yield* rpc.call(Failing.id, "standard", undefined).pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.client(Failing).standard().pipe(Effect.flip)).toEqual({
type: "missing",
message: "Missing",
data: { attempts: 2 },
})
expect(yield* rpc.call(Failing.id, "effect", undefined).pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: "3" },
})
expect(yield* rpc.client(Failing).effect().pipe(Effect.flip)).toEqual({
type: "invalid",
message: "Invalid",
data: { count: 3 },
})
}),
)
it.effect("fails in-process callers with a typed rpc.internal error for defects and undeclared errors", () => {
const logged: unknown[] = []
const logger = Logger.make((entry) => {
if (entry.logLevel === "Error") logged.push(entry.message)
})
return Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Broken = Rpc.define({
id: "broken",
methods: {
dies: { input: z.undefined(), output: z.string() },
throws: { input: z.undefined(), output: z.string() },
raw: { input: z.undefined(), output: z.string() },
undeclared: { input: z.undefined(), output: z.string(), errors: { known: z.object({}) } },
},
events: {},
})
yield* rpc.register(Broken, {
dies: () => Effect.die(new Error("handler defect")),
throws: () => {
throw new Error("handler threw")
},
// @ts-expect-error the Promise adapter fails with raw thrown values that the types otherwise forbid
raw: () => Effect.fail(new Error("raw failure")),
// @ts-expect-error undeclared error names are rejected statically; the runtime contract is under test
undeclared: (_input, context) => Effect.fail(context.error("unknown", "Unknown")),
})
const client = rpc.client(Broken)
const internal = { type: "rpc.internal" as const, message: "RPC call failed" }
// Every path yields the same typed failure the HTTP handler already exposes, so callers can recover.
expect(yield* client.dies().pipe(Effect.flip)).toEqual(internal)
expect(yield* client.throws().pipe(Effect.flip)).toEqual(internal)
expect(yield* client.raw().pipe(Effect.flip)).toEqual(internal)
expect(yield* rpc.call(Broken.id, "dies", undefined).pipe(Effect.flip)).toEqual(internal)
expect(yield* client.undeclared().pipe(Effect.flip)).toEqual({
type: "rpc.internal",
message: "Undeclared RPC error: unknown",
})
expect(
yield* client.dies().pipe(
Effect.catchIf(
(error) => error.type === "rpc.internal",
(error) => Effect.succeed(`recovered:${error.type}`),
),
),
).toBe("recovered:rpc.internal")
// Each conversion logs its cause once so the detail is not lost.
expect(logged).toHaveLength(6)
expect(logged[0]).toEqual([
"rpc handler failed",
{ rpc: "broken", method: "dies", error: new Error("handler defect") },
])
expect(logged[2]).toEqual([
"rpc handler failed",
{ rpc: "broken", method: "raw", error: new Error("raw failure") },
])
}).pipe(Effect.provideService(Logger.CurrentLoggers, new Set([logger])))
})
it.effect("keeps other event consumers running after one subscription ends", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const registration = yield* rpc.register(Echo, { echo: (value) => Effect.succeed(value) })
const client = rpc.client(Echo)
const first = yield* client.events.subscribe("updated").pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const second = yield* client.events
.subscribe("updated")
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* registration.events.emit("updated", { text: "first" })
const received = yield* Fiber.join(first)
expect(received.map((event) => event.data.text)).toEqual(["first"])
Reflect.set(received[0].location, "directory", "/consumer-mutated")
yield* registration.events.emit("updated", { text: "second" })
expect((yield* Fiber.join(second)).map((event) => event.data.text)).toEqual(["first", "second"])
}),
)
it.effect("validates plain JSON Schema inputs and outputs without type inference", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Raw = Rpc.define({
id: "raw",
methods: { count: { input: { type: "integer", minimum: 0 }, output: { type: "integer", minimum: 1 } } },
events: {
counted: {
schema: {
type: "object",
properties: { count: { type: "integer", minimum: 1 } },
required: ["count"],
additionalProperties: false,
},
},
},
})
const registration = yield* rpc.register(Raw, { count: (value) => Effect.succeed(value) })
expect(yield* rpc.call(Raw.id, "count", 42)).toBe(42)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
}),
)
it.effect("supports methods with no input and no returned value", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const Empty = Rpc.define({
id: "empty",
methods: { ping: { input: z.undefined(), output: z.undefined() } },
events: {},
})
yield* rpc.register(Empty, { ping: () => Effect.undefined })
expect(yield* rpc.client(Empty).ping()).toBeUndefined()
}),
)
it.effect("keeps in-flight calls on their original implementation after removal", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const registration = yield* rpc.register(Echo, {
echo: (value) =>
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(value)),
})
const call = yield* rpc.client(Echo).echo("original").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* registration.dispose
yield* rpc.register(Echo, { echo: () => Effect.succeed("replacement") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("replacement")
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(call)).toBe("original")
}),
)
it.effect("interrupts the running Effect handler when its call is cancelled", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
yield* rpc.register(Echo, {
echo: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
),
})
const call = yield* rpc.client(Echo).echo("hello").pipe(Effect.forkScoped)
yield* Deferred.await(started)
yield* Fiber.interrupt(call)
yield* Deferred.await(stopped)
}),
)
it.effect("isolates registrations and subscriptions while publishing location-tagged events on the shared bus", () =>
Effect.gen(function* () {
const rpc = yield* Rpc.Service
const bus = yield* Bus.Service
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
const otherContext = yield* Layer.build(
LayerNode.compile(Rpc.node, {
replacements: [
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Location.node.replace(Layer.succeed(Location.Service, location(otherRef))),
],
}).pipe(Layer.fresh),
)
const other = Context.get(otherContext, Rpc.Service)
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
expect(Exit.isFailure(yield* other.client(Echo).echo("hello").pipe(Effect.exit))).toBe(true)
const second = yield* other.register(Echo, { echo: () => Effect.succeed("second") })
expect(yield* rpc.client(Echo).echo("hello")).toBe("first")
expect(yield* other.client(Echo).echo("hello")).toBe("second")
const all: Event.Payload[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
all.push(event)
}),
)
const localEvents = yield* rpc
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const otherEvents = yield* other
.client(Echo)
.events.subscribe("updated")
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* second.events.emit("updated", { text: "second" })
yield* first.events
.emit("updated", { text: "first" })
.pipe(Effect.provideService(Location.Service, location(otherRef)))
expect((yield* Fiber.join(localEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "first" },
location: ref,
})
expect((yield* Fiber.join(otherEvents))[0]).toMatchObject({
type: "rpc.test.rpc.updated",
data: { text: "second" },
location: otherRef,
})
expect(all.map((event) => event.location)).toEqual([otherRef, ref])
yield* unsubscribe
}),
)
})
-1
View File
@@ -36,7 +36,6 @@
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
@@ -1,286 +0,0 @@
import type { Browser } from "@opencode-ai/schema/browser"
import electron, { type BrowserWindow } from "electron"
type AXNode = {
nodeId: string
backendDOMNodeId?: number
childIds?: string[]
ignored?: boolean
role?: { value?: string }
name?: { value?: unknown }
properties?: Array<{ name: string; value?: { value?: unknown } }>
}
export type BrowserPage = ReturnType<typeof createBrowserPage>
export function createBrowserPage(win: BrowserWindow, publish: (error?: string) => void, fail: () => void) {
const view = new electron.WebContentsView({
webPreferences: {
partition: `opencode-browser-${crypto.randomUUID()}`,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
webviewTag: false,
devTools: false,
disableDialogs: true,
},
})
const contents = view.webContents
const refs = new Map<string, { id: number; editable: boolean }>()
let generation = 0
let nextRef = 0
let closed = false
const state = (): Browser.State => ({
url: contents.getURL().slice(0, 16_384),
title: contents.getTitle().slice(0, 1_024),
loading: contents.isLoading(),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
generation,
})
const update = () => {
if (!closed) publish()
}
contents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
const step =
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
? 0.5
: input.key === "-" || input.code === "NumpadSubtract"
? -0.5
: 0
if (!step && input.key !== "0") return
event.preventDefault()
contents.setZoomLevel(input.key === "0" ? 0 : contents.getZoomLevel() + step)
})
const session = contents.session
session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false))
session.setPermissionCheckHandler(() => false)
session.setDevicePermissionHandler(() => false)
session.setDisplayMediaRequestHandler((_request, callback) => callback({}))
session.on("will-download", (event) => event.preventDefault())
contents.setWindowOpenHandler(() => ({ action: "deny" }))
contents.on("content-bounds-updated", (event) => event.preventDefault())
const guard = (event: Electron.Event<{ url: string }>) => {
if (event.url === "about:blank" || destinationOrigin(event.url)) return
event.preventDefault()
publish("ERR_BLOCKED_BY_CLIENT")
}
contents.on("will-frame-navigate", guard)
contents.on("will-redirect", guard)
contents.on("did-stop-loading", update)
contents.on("did-navigate-in-page", update)
contents.on("page-title-updated", update)
contents.on("did-start-navigation", (event) => {
if (!event.isMainFrame) return
generation++
refs.clear()
update()
})
contents.on("did-fail-load", (_event, code, error, _url, mainFrame) => {
if (!closed && mainFrame && code !== -3) publish(error)
})
contents.on("render-process-gone", () => {
if (!closed) fail()
})
contents.debugger.on("detach", () => {
if (!closed) fail()
})
view.setVisible(false)
// Match the review pane card so the page does not leak past its rounded bottom corners.
view.setBorderRadius(10)
win.contentView.addChildView(view)
return {
view,
state,
execute,
ready: Promise.resolve().then(() => contents.loadURL("about:blank")),
dispose() {
if (closed) return
closed = true
refs.clear()
if (!win.isDestroyed()) win.contentView.removeChildView(view)
if (!contents.isDestroyed()) contents.close({ waitForBeforeUnload: false })
},
}
async function execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
const action = command.action
check()
if (action.type === "navigate") {
await navigate(action.url, signal)
return { type: "state", state: state() }
}
if (["open", "back", "forward", "reload", "stop"].includes(action.type)) {
if (action.type === "stop") contents.stop()
if (action.type === "reload") contents.reload()
if (action.type === "back" && contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
if (action.type === "forward" && contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
return { type: "state", state: state() }
}
if (action.type === "evaluate") {
const value: unknown = await contents.executeJavaScript(action.script)
check()
return { type: "evaluate", state: state(), content: (JSON.stringify(value) ?? "null").slice(0, 100_000) }
}
if (action.type === "snapshot") {
const tree = (await send("Accessibility.getFullAXTree", { depth: 6 })) as { nodes: AXNode[] }
refs.clear()
const nodes = new Map(tree.nodes.map((node) => [node.nodeId, node]))
const lines = [`Page: ${clean(state().title)}`, `URL: ${state().url}`, ""]
if (tree.nodes[0]) walk(tree.nodes[0], 0)
return {
type: "snapshot",
state: state(),
content: lines.join("\n").slice(0, 40_960),
}
function walk(node: AXNode, depth: number) {
if (depth > 6 || lines.length >= 503) return
const role = (node.role?.value ?? "node").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40)
const properties = new Map((node.properties ?? []).map((item) => [item.name, item.value?.value]))
const editable =
["textbox", "searchbox", "combobox", "spinbutton"].includes(role) || !!properties.get("editable")
if (!node.ignored) {
const actionable = properties.get("focusable") || /^(button|link|textbox|combobox)$/.test(role)
const ref = actionable && node.backendDOMNodeId ? `e${++nextRef}` : ""
if (ref && node.backendDOMNodeId)
refs.set(ref, {
id: node.backendDOMNodeId,
editable: editable && !properties.get("disabled") && !properties.get("readonly"),
})
const flags = ["checked", "disabled", "expanded", "selected"].flatMap((flag) =>
properties.has(flag) ? [`${flag}=${properties.get(flag)}`] : [],
)
lines.push(
`${" ".repeat(depth)}${ref ? `@${ref}` : ""} [${role}] ${JSON.stringify(clean(node.name?.value))} ${flags.join(" ")}`,
)
}
// Editable descendants can repeat the field's value as static text.
if (!editable)
node.childIds?.forEach((id) => {
const child = nodes.get(id)
if (child) walk(child, depth + 1)
})
}
}
if (action.type === "screenshot") {
const source = await contents.capturePage()
check()
const size = source.getSize()
if (!size.width || !size.height) throw new Error("internal")
const scale = Math.min(1, 2_000 / Math.max(size.width, size.height))
const image = source.resize({
width: Math.max(1, Math.round(size.width * scale)),
height: Math.max(1, Math.round(size.height * scale)),
})
const data = new Uint8Array(image.toPNG())
if (data.byteLength > 5 * 1_024 * 1_024) throw new Error("result_too_large")
return { type: "screenshot", state: state(), data }
}
if (action.type === "click" || action.type === "fill") {
const target = refs.get(action.ref.replace(/^@/, ""))
if (!target || (action.type === "fill" && !target.editable)) throw new Error("stale_ref")
if (action.type === "fill") {
await send("DOM.focus", { backendNodeId: target.id })
await key({ key: "a", code: "KeyA", modifiers: process.platform === "darwin" ? 4 : 2 })
await key({ key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 })
await send("Input.insertText", { text: action.text })
}
if (action.type === "click") {
await send("DOM.scrollIntoViewIfNeeded", { backendNodeId: target.id })
const result = (await send("DOM.getBoxModel", { backendNodeId: target.id })) as {
model: { content: number[] }
}
const box = result.model.content
const point = { x: (box[0] + box[4]) / 2, y: (box[1] + box[5]) / 2 }
for (const type of ["mouseMoved", "mousePressed", "mouseReleased"]) {
await send("Input.dispatchMouseEvent", { type, ...point, button: "left", clickCount: 1 })
}
}
}
if (action.type === "press") {
const codes: Record<Browser.Key, number> = {
Enter: 13,
Tab: 9,
Escape: 27,
Backspace: 8,
Delete: 46,
ArrowUp: 38,
ArrowDown: 40,
ArrowLeft: 37,
ArrowRight: 39,
PageUp: 33,
PageDown: 34,
Home: 36,
End: 35,
Space: 32,
}
await key({
key: action.key === "Space" ? " " : action.key,
code: action.key,
windowsVirtualKeyCode: codes[action.key],
})
}
if (action.type === "scroll") {
const bounds = view.getBounds()
await send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: bounds.width / 2,
y: bounds.height / 2,
deltaX: action.direction === "left" ? -action.pixels : action.direction === "right" ? action.pixels : 0,
deltaY: action.direction === "up" ? -action.pixels : action.direction === "down" ? action.pixels : 0,
})
}
check()
return { type: "state", state: state() }
function check() {
if (closed) throw new Error("not_attached")
if (signal.aborted) throw new Error("aborted")
// Opening may create a new document before the command runs.
if (action.type !== "open" && generation !== command.generation) throw new Error("stale_ref")
}
function key(params: Record<string, unknown>) {
return send("Input.dispatchKeyEvent", { type: "keyDown", ...params }).finally(() =>
contents.debugger.sendCommand("Input.dispatchKeyEvent", { type: "keyUp", ...params }),
)
}
async function send(method: string, params: Record<string, unknown>): Promise<unknown> {
check()
if (!contents.debugger.isAttached()) contents.debugger.attach("1.3")
const result: unknown = await contents.debugger.sendCommand(method, params)
check()
return result
}
}
async function navigate(input: string, signal: AbortSignal) {
const value = input.trim() || "about:blank"
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
const url =
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value)
? value
: `${local ? "http" : "https"}://${value}`
if (url.length > 16_384 || (url !== "about:blank" && !destinationOrigin(url))) throw new Error("invalid_url")
const cancel = () => {
if (!closed) contents.stop()
}
signal.addEventListener("abort", cancel, { once: true })
await contents.loadURL(url).finally(() => signal.removeEventListener("abort", cancel))
if (signal.aborted) throw new Error("aborted")
}
}
function clean(value: unknown) {
return typeof value === "string" ? value.replaceAll(/\s+/g, " ").trim().slice(0, 300) : ""
}
export function destinationOrigin(input: string) {
if (!URL.canParse(input)) return
const url = new URL(input)
return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined
}
@@ -1,15 +0,0 @@
import { expect, test } from "bun:test"
import { destinationOrigin } from "./browser-chromium"
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
expect(destinationOrigin("http://localhost:3000/")).toBe("http://localhost:3000")
for (const url of [
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,test",
"https://user:pass@example.com",
]) {
expect(destinationOrigin(url)).toBeUndefined()
}
})
-254
View File
@@ -1,254 +0,0 @@
import type { BrowserPaneCommand, BrowserPaneLayout, BrowserPaneTarget } from "@opencode-ai/app/desktop"
import { NodeHttpClient } from "@effect/platform-node"
import { Browser } from "@opencode-ai/schema/browser"
import { OpenCode } from "@opencode-ai/client/effect"
import { SessionID } from "@opencode-ai/schema/session-id"
import type { BrowserWindow } from "electron"
import { Deferred, Effect, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
import { createBrowserPage, destinationOrigin, type BrowserPage } from "./browser-chromium"
import { emitIpcEvent } from "./ipc-events"
type Entry = {
bindingID: string
win: BrowserWindow
abort: AbortController
registered: PromiseWithResolvers<void>
requests: Map<string, AbortController>
report?: (event: Extract<BrowserPaneEvent["event"], { type: "state" }>) => void
cleanup?: () => void
page?: BrowserPage
}
export function createBrowserPane() {
const entries = new Map<string, Entry>()
// Keep long-lived RPC requests off Chromium's shared HTTP connection pool.
const runtime = ManagedRuntime.make(NodeHttpClient.layerNodeHttp)
let disposed = false
return {
async register(win: BrowserWindow, bindingID: string, target: BrowserPaneTarget) {
if (disposed || !destinationOrigin(target.endpoint.url)) throw new Error("browser.pane.registration.invalid")
if (target.endpoint.username && !target.endpoint.password) throw new Error("browser.pane.endpoint.invalid")
if (entries.has(bindingID)) throw new Error("browser.pane.owner.invalid")
if (win.isDestroyed() || win.webContents.isDestroyed()) throw new Error("browser.pane.owner.unavailable")
const sessionID = SessionID.make(target.sessionID)
const entry: Entry = {
bindingID,
win,
abort: new AbortController(),
registered: Promise.withResolvers(),
requests: new Map(),
}
// "unsupported" means the server has no browser plugin; the renderer stops retrying.
let reason: "browser.pane.unsupported" | undefined
const stop = () => close(entry, reason)
const navigate = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
if (event.isMainFrame && !event.isSameDocument) stop()
}
win.webContents.once("destroyed", stop)
win.webContents.on("did-start-navigation", navigate)
entry.cleanup = () => {
win.webContents.off("destroyed", stop)
win.webContents.off("did-start-navigation", navigate)
}
entries.set(bindingID, entry)
void runtime
.runPromise(
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const client = yield* OpenCode.make({ baseUrl: target.endpoint.url }).pipe(
Effect.provideService(
HttpClient.HttpClient,
target.endpoint.password
? HttpClient.mapRequest(
http,
HttpClientRequest.basicAuth(target.endpoint.username ?? "opencode", target.endpoint.password),
)
: http,
),
)
const session = yield* client.session.get({ sessionID })
const options = {
location: { directory: session.location.directory, workspace: session.location.workspaceID },
}
const attachment = { sessionID, connectionID: crypto.randomUUID() }
const rpc = client.rpc(Browser.Definition)
const connected = yield* Deferred.make<void>()
const outbound = yield* Queue.unbounded<Effect.Effect<void, unknown>>()
// Report state before publishing it locally or completing a command.
entry.report = (event) => {
Queue.offerUnsafe(
outbound,
rpc
.state({ ...attachment, state: event.state }, options)
.pipe(Effect.tap(() => Effect.sync(() => publish(entry, event)))),
)
}
const receive = client.event.subscribe().pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (event.type === "server.connected") {
yield* Deferred.succeed(connected, undefined)
return
}
if (event.type !== "rpc.experimental.browser.control") return
const message = yield* Schema.decodeUnknownEffect(Browser.Control)(event.data)
if (message.connectionID !== attachment.connectionID) return
if (message.type === "attached") return entry.registered.resolve()
if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort()
const abort = new AbortController()
entry.requests.set(message.requestID, abort)
yield* Effect.promise(async () => {
const outcome: Browser.Outcome = await execute(entry, message.command, abort.signal).then(
(result) => ({ type: "success" as const, result }),
(error: unknown) => ({
type: "failure" as const,
message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
}),
)
Queue.offerUnsafe(
outbound,
rpc.result(
{
...attachment,
requestID: message.requestID,
outcome: Schema.encodeSync(Browser.Outcome)(outcome),
},
options,
),
)
}).pipe(
Effect.ensuring(
Effect.sync(() => {
abort.abort()
entry.requests.delete(message.requestID)
}),
),
Effect.catchCause(() => Effect.sync(stop)),
Effect.forkScoped,
)
}),
),
)
yield* Effect.raceAllFirst([
receive,
Stream.fromQueue(outbound).pipe(Stream.runForEach((send) => send)),
Deferred.await(connected).pipe(Effect.andThen(rpc.attach(attachment, options))),
])
}).pipe(
Effect.scoped,
Effect.tapError((error) =>
Effect.sync(() => {
const type = error instanceof Object && "type" in error ? error.type : undefined
if (type === "rpc.unavailable" || type === "rpc.method_not_found") reason = "browser.pane.unsupported"
}),
),
Effect.ensuring(Effect.sync(stop)),
),
{ signal: entry.abort.signal },
)
.catch(stop)
const timeout = setTimeout(stop, 15_000)
await entry.registered.promise.finally(() => clearTimeout(timeout))
if (entries.get(bindingID) !== entry) throw new Error("browser.pane.registration.closed")
publishState(entry)
},
layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) {
const entry = owned(win, bindingID)
if (!value) return closePage(entry)
const bounds = value.bounds
if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) {
entry.page?.view.setVisible(false)
return
}
const page = create(entry)
page.view.setBounds(bounds)
page.view.setVisible(true)
},
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
const entry = owned(win, bindingID)
await execute(
entry,
{ action: command, generation: entry.page?.state().generation ?? 0 },
new AbortController().signal,
)
},
async close(win: BrowserWindow, bindingID: string) {
close(owned(win, bindingID))
},
async dispose() {
disposed = true
entries.forEach(close)
await runtime.dispose()
},
}
function owned(win: BrowserWindow, bindingID: string) {
const entry = entries.get(bindingID)
if (!entry || entry.win !== win) throw new Error("browser.pane.unavailable")
return entry
}
function publish(entry: Entry, event: BrowserPaneEvent["event"]) {
if (!entries.has(entry.bindingID) || entry.win.isDestroyed() || entry.win.webContents.isDestroyed()) return
emitIpcEvent(entry.win.webContents, new BrowserPaneEvent({ bindingID: entry.bindingID, event }))
}
function close(entry: Entry, reason = "browser.pane.registration.closed") {
if (entries.get(entry.bindingID) !== entry) return
entry.report = undefined
closePage(entry, reason)
entries.delete(entry.bindingID)
entry.registered.reject(new Error("browser.pane.registration.closed"))
entry.cleanup?.()
entry.abort.abort()
}
function closePage(entry: Entry, error?: string) {
entry.requests.forEach((request) => request.abort())
entry.requests.clear()
entry.page?.dispose()
entry.page = undefined
publishState(entry, error)
}
function publishState(entry: Entry, error?: string) {
const event = {
type: "state" as const,
state: entry.page?.state() ?? null,
...(error === undefined ? {} : { error }),
}
if (entry.report) return entry.report(event)
publish(entry, event)
}
function create(entry: Entry) {
if (entry.page) return entry.page
const fail = () => {
if (entry.page === page) closePage(entry, "page_crashed")
}
const page = createBrowserPage(
entry.win,
(error) => {
if (entry.page === page) publishState(entry, error)
},
fail,
)
entry.page = page
void page.ready
.then(() => {
if (entry.page === page) publishState(entry)
})
.catch(fail)
return page
}
async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) {
if (command.action.type === "open") publish(entry, { type: "open" })
const page = command.action.type === "open" ? create(entry) : entry.page
if (!page) throw new Error("not_attached")
await page.ready
return page.execute(command, signal)
}
}
@@ -1,35 +1,14 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { createBrowserPane } from "../browser-pane"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/protocol"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const shutdown = yield* Shutdown.Service
const browser = createBrowserPane()
const stop = Effect.promise(() => browser.dispose())
const remove = yield* shutdown.add(stop)
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
BrowserPane: ({ request }, context) =>
Effect.tryPromise(async () => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
throw new Error("browser.pane.owner.invalid")
}
if (request.type === "register") return browser.register(win, request.bindingID, request.target)
if (request.type === "layout") return browser.layout(win, request.bindingID, request.layout)
if (request.type === "command") return browser.command(win, request.bindingID, request.command)
return browser.close(win, request.bindingID)
}).pipe(Effect.orDie),
})
}),
)
@@ -1,9 +1,7 @@
import type { BrowserPaneEvent } from "@opencode-ai/app/desktop"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -25,11 +23,6 @@ export type UpdaterAPI = {
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
reconnectService(): Promise<ServerReadyData>
browserPane: {
request(request: BrowserPaneRequest): Promise<void>
send(request: BrowserPaneRequest): void
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
}
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
-5
View File
@@ -25,11 +25,6 @@ const updaterHandler = (state: UpdaterState) => {
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
browserPane: {
request: (request) => invoke("BrowserPane", { request }),
send: (request) => send("BrowserPane", { request }),
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(mutable(value))),
},
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
@@ -30,35 +30,6 @@ export function createDesktopPlatform(
windowID: windowState.id,
...createDesktopFiles(api, os, ACCEPTED_FILE_EXTENSIONS),
...createDesktopStorage(api),
browserPane: {
register(target, onEvent) {
const bindingID = crypto.randomUUID()
let closed = false
const dispose = api.browserPane.onEvent((value) => {
if (!closed && value.bindingID === bindingID) onEvent(value.event)
})
const ready = api.browserPane.request({ type: "register", bindingID, target })
// Failures reach the owner through the closed-state event; keep the bare promise handled.
void ready.catch(() => undefined)
return {
setLayout(layout) {
if (!closed)
void ready
.then(() =>
api.browserPane.send({ type: "layout", bindingID, ...(layout === undefined ? {} : { layout }) }),
)
.catch(() => undefined)
},
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
close() {
if (closed) return
closed = true
dispose()
void ready.then(() => api.browserPane.request({ type: "close", bindingID })).catch(() => undefined)
},
}
},
},
updater,
exportDebugLogs: () => api.exportDebugLogs(),
setForceFocus: (enabled) => api.setForceFocus(enabled),
@@ -1,31 +0,0 @@
import { Browser } from "@opencode-ai/schema/browser"
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
const bindingID = text(128)
const endpoint = Schema.Struct({
url: text(16_384),
username: Schema.optionalKey(text(1_024)),
password: Schema.optionalKey(text(4_096)),
})
const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint })
const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite })
const layout = Schema.Struct({ visible: Schema.Boolean, bounds: Schema.optionalKey(bounds) })
export const BrowserPaneRequestSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("register"), bindingID, target }),
Schema.Struct({ type: Schema.Literal("layout"), bindingID, layout: Schema.optionalKey(layout) }),
Schema.Struct({ type: Schema.Literal("command"), bindingID, command: Browser.Action }),
Schema.Struct({ type: Schema.Literal("close"), bindingID }),
])
export type BrowserPaneRequest = Schema.Schema.Type<typeof BrowserPaneRequestSchema>
export const BrowserPaneEventSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("open") }),
Schema.Struct({
type: Schema.Literal("state"),
state: Schema.NullOr(Browser.State),
error: Schema.optionalKey(Schema.String),
}),
])
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
@@ -1,14 +1,8 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
export class BrowserPaneEvent extends Schema.TaggedClass<BrowserPaneEvent>()("BrowserPaneEvent", {
bindingID: Schema.String,
event: BrowserPaneEventSchema,
}) {}
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
urls: Schema.Array(Schema.String),
}) {}
@@ -38,7 +32,6 @@ export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("
}) {}
export const DesktopEvent = Schema.Union([
BrowserPaneEvent,
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
@@ -50,4 +43,4 @@ export const DesktopEvent = Schema.Union([
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
export const EventRpcs = RpcGroup.make(DesktopEvents)
-37
View File
@@ -1,37 +0,0 @@
# Experimental Browser Plugin
`@opencode-ai/plugin-browser` implements the server-side browser tool using only
the public plugin API, public schemas, and Effect. Core registers it as a built-in
plugin; the package does not depend on Core or Server. The shared RPC contract is
`@opencode-ai/schema/browser`; desktop clients do not import Core.
Disable it through normal plugin configuration:
```jsonc
{
"plugins": ["-opencode.browser"],
}
```
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
session's location. Subscribe to server events before calling `attach`; wait for
`server.connected`, then the matching `attached` control event. The `attach` call
stays pending for the attachment lifetime. Abort it when its event stream ends or
the desktop owner closes. Completing the attachment also ends that event consumer.
- `attach` holds one browser attachment per session until cancellation, plugin
unload, session deletion, or session movement.
- `state` reports the current page, or `null` when no page is open.
- `result` completes a command with its request ID and outcome.
- `control` events carry attachment confirmation, commands, and cancellation.
Control events use OpenCode's existing authenticated, server-wide event feed.
Consumers filter by `connectionID`; this identifier is correlation, not private
event delivery. State and results use RPC calls rather than broadcast events.
Per-URL permission checks are deferred to the final permission layer (#46530).
Until that layer lands, browser actions do not enforce URL-specific ask or deny
rules. Attachment ownership, page validation, and cancellation remain enforced.
Browser content is untrusted. Pages use the desktop's network, with no server-side
tunnel. The desktop owns Chromium, page isolation, and native controls.
-37
View File
@@ -1,37 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/plugin-browser"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import pkg from "../package.json"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
await $`bun run typecheck`
await $`bun run build`
const original = await Bun.file("package.json").text()
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
try {
await Bun.write(
"package.json",
JSON.stringify(
{
...pkg,
exports: { ".": { import: "./dist/index.js", types: "./dist/index.d.ts" } },
},
null,
2,
) + "\n",
)
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", original)
await rm(tarball, { force: true })
}
-177
View File
@@ -1,177 +0,0 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Session } from "@opencode-ai/schema/session"
import { Tool } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Encoding, Stream } from "effect"
import { Browser } from "@opencode-ai/schema/browser"
type Attachment = {
connectionID: string
state: Browser.State | null
closed: Deferred.Deferred<void>
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
}
export default Plugin.define({
id: "opencode.browser",
effect: (ctx) =>
Effect.gen(function* () {
const browsers = new Map<Session.ID, Attachment>()
let active = true
const close = (sessionID: Session.ID) =>
Effect.gen(function* () {
const browser = browsers.get(sessionID)
if (!browser) return
browsers.delete(sessionID)
yield* Deferred.succeed(browser.closed, undefined)
})
yield* Effect.addFinalizer(() => {
active = false
return Effect.forEach(browsers.keys(), close, { discard: true })
})
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
.register(Browser.Definition, {
attach: (input, call) =>
Effect.gen(function* () {
const session = yield* ctx.session
.get({ sessionID: input.sessionID })
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
if (
session.location.directory !== ctx.location.directory ||
session.location.workspaceID !== ctx.location.workspaceID
)
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
const browser = yield* Effect.acquireRelease(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>()
if (!active) return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
// The newest desktop attachment wins so a re-register that races the
// previous connection's teardown does not leave the session detached.
yield* close(input.sessionID)
const browser: Attachment = {
connectionID: input.connectionID,
state: null,
closed,
pending: new Map(),
}
browsers.set(input.sessionID, browser)
return browser
}),
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
state: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
browser.state = input.state
}),
result: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const pending = browser.pending.get(input.requestID)
if (!pending) return
if (input.outcome.type === "failure")
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
Effect.asVoid,
)
yield* Deferred.succeed(pending, input.outcome.result)
}).pipe(Effect.asVoid),
})
.pipe(Effect.orDie)
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "browser",
input: Browser.Action,
options: { codemode: false },
description:
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
execute: (action, tool) =>
Effect.gen(function* () {
const browser = browsers.get(tool.sessionID)
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
if (action.type !== "open" && !browser.state)
return yield* new Tool.Error({ message: "Open the browser first." })
const requestID = crypto.randomUUID()
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
browser.pending.set(requestID, pending)
const result = yield* rpc.events
.emit("control", {
type: "command",
connectionID: browser.connectionID,
requestID,
command: { action, generation: browser.state?.generation ?? 0 },
})
.pipe(
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
Effect.andThen(Deferred.await(pending)),
Effect.raceFirst(
Deferred.await(browser.closed).pipe(
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
),
),
Effect.onInterrupt(() =>
rpc.events
.emit("control", {
type: "cancel",
connectionID: browser.connectionID,
requestID,
})
.pipe(Effect.ignore),
),
Effect.timeoutOrElse({
duration: "30 seconds",
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
}),
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
)
return render(result)
}),
}),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
if (!browsers.has(event.sessionID)) delete event.tools.browser
}),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
Stream.runForEach((event) => close(event.data.sessionID)),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function render(result: Browser.Result): Tool.Result {
if (result.type === "screenshot")
return {
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{
type: "file",
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
mime: "image/png",
name: "browser-screenshot.png",
},
],
metadata: { url: result.state.url },
}
const content = JSON.stringify(result)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
return {
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
metadata: { url: result.state.url },
}
}
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"noEmit": false
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
-4
View File
@@ -5,14 +5,12 @@
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit",
"build": "tsc -p tsconfig.build.json"
},
"exports": {
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./host": "./src/host.ts",
"./tui": "./src/tui/index.ts",
"./*": "./src/*.ts"
},
@@ -25,7 +23,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:"
@@ -56,7 +53,6 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
-48
View File
@@ -1,48 +0,0 @@
export * as Host from "./host.js"
import path from "node:path"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export interface Target {
readonly directory: string
readonly name?: string
}
export interface Entrypoints {
readonly server?: string
readonly tui?: string
readonly rpc?: string
}
export function resolve(target: Target): Entrypoints {
const entry = (subpaths: readonly string[]) => {
for (const subpath of subpaths) {
const specifier = target.name
? [target.name, subpath].filter(Boolean).join("/")
: path.resolve(target.directory, subpath || "index")
try {
return resolveModule(specifier, target.directory)
} catch (error) {
if (
!(error instanceof Error) ||
!("code" in error) ||
![
"ENOENT",
"ENOTDIR",
"MODULE_NOT_FOUND",
"ERR_MODULE_NOT_FOUND",
"ERR_PACKAGE_PATH_NOT_EXPORTED",
"ERR_UNSUPPORTED_DIR_IMPORT",
].includes(String(error.code))
)
throw error
}
}
return undefined
}
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
}
export function load(entrypoint: string): Promise<unknown> {
return importModule(entrypoint)
}
-156
View File
@@ -1,156 +0,0 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
import { pathToFileURL } from "node:url"
import { Host } from "../src/host.js"
// Every entrypoint throws if evaluated: resolution must never execute plugins.
const source = 'throw new Error("Plugin code must not run during resolution")'
const name = "@fixture/plugin"
async function fixture(files: Record<string, string>, installed = false) {
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
const directory = installed ? path.join(root, "node_modules", name) : root
await Promise.all(
Object.entries(files).map(async ([file, content]) => {
await mkdir(path.dirname(path.join(directory, file)), { recursive: true })
await writeFile(path.join(directory, file), content)
}),
)
return {
target: { directory, ...(installed ? { name } : {}) },
url: (file: string) => pathToFileURL(path.join(directory, file)).href,
[Symbol.asyncDispose]: () => rm(root, { recursive: true, force: true }),
}
}
describe("Host.resolve", () => {
it("resolves conventional entrypoints without package.json", async () => {
await using plugin = await fixture({ "index.ts": source, "tui.tsx": source, "rpc.ts": source })
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("index.ts"),
tui: plugin.url("tui.tsx"),
rpc: plugin.url("rpc.ts"),
})
})
it("resolves a TUI-only directory without an index or package.json", async () => {
await using plugin = await fixture({ "tui.tsx": source })
assert.deepEqual(Host.resolve(plugin.target), {
server: undefined,
tui: plugin.url("tui.tsx"),
rpc: undefined,
})
})
for (const main of [undefined, "lib/backend.js"]) {
it(`resolves packages without exports using ${main ?? "the default index"}`, async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({ name, main }),
[main ?? "index.js"]: source,
"tui.js": source,
"rpc.js": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url(main ?? "index.js"),
tui: plugin.url("tui.js"),
rpc: plugin.url("rpc.js"),
})
})
}
it("resolves local conventional entrypoints with package.json but no exports", async () => {
await using plugin = await fixture({
"package.json": JSON.stringify({ name, type: "module" }),
"index.js": source,
"tui.js": source,
"rpc.js": source,
})
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("index.js"),
tui: plugin.url("tui.js"),
rpc: plugin.url("rpc.js"),
})
})
it("honors exports and prefers the explicit server entrypoint over the root", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({
name,
exports: {
".": "./dist/root.js",
"./server": "./dist/backend.js",
"./tui": "./dist/terminal.js",
"./rpc": "./dist/contract.js",
},
}),
"dist/root.js": source,
"dist/backend.js": source,
"dist/terminal.js": source,
"dist/contract.js": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("dist/backend.js"),
tui: plugin.url("dist/terminal.js"),
rpc: plugin.url("dist/contract.js"),
})
})
it("falls back to the root export and uses import rather than require conditions", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({
name,
exports: {
".": { import: "./dist/server.mjs", require: "./dist/server.cjs" },
"./tui": { import: "./dist/tui.mjs", require: "./dist/tui.cjs" },
},
}),
"dist/server.mjs": source,
"dist/server.cjs": source,
"dist/tui.mjs": source,
"dist/tui.cjs": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("dist/server.mjs"),
tui: plugin.url("dist/tui.mjs"),
rpc: undefined,
})
})
it("supports TUI-only exports without falling back to unexported files", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({ name, exports: { "./tui": "./dist/terminal.js" } }),
"dist/terminal.js": source,
"index.js": source,
"server.js": source,
"rpc.js": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: undefined,
tui: plugin.url("dist/terminal.js"),
rpc: undefined,
})
})
it("does not return an exported entrypoint whose file is missing", async () => {
await using plugin = await fixture(
{ "package.json": JSON.stringify({ name, exports: { "./tui": "./missing.js" } }) },
true,
)
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
})
})
+1 -1
View File
@@ -6,7 +6,7 @@
- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`.
- Schema values should be serializable contract definitions, not service implementations or runtime registries.
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.updated` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports.
## Current Versus V1
-123
View File
@@ -1,123 +0,0 @@
export * as Browser from "./browser.js"
import { Schema } from "effect"
import { Rpc } from "./rpc.js"
import { Session } from "./session.js"
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/))
.pipe(Schema.brand("Browser.Ref"))
.annotate({ identifier: "Browser.Ref" })
export type Ref = typeof Ref.Type
export interface State extends Schema.Schema.Type<typeof State> {}
export const State = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: Schema.String.check(Schema.isMaxLength(1_024)),
loading: Schema.Boolean,
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "Browser.State" })
export const Key = Schema.Literals([
"Enter",
"Tab",
"Escape",
"Backspace",
"Delete",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
"Space",
]).annotate({ identifier: "Browser.Key" })
export type Key = typeof Key.Type
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" })
export type Direction = typeof Direction.Type
export const Action = Schema.Union([
Schema.Struct({ type: Schema.Literals(["open", "snapshot", "screenshot", "back", "forward", "reload", "stop"]) }),
Schema.Struct({ type: Schema.Literal("navigate"), url: Schema.String.check(Schema.isMaxLength(16_384)) }),
Schema.Struct({ type: Schema.Literal("click"), ref: Ref }),
Schema.Struct({ type: Schema.Literal("fill"), ref: Ref, text: Schema.String.check(Schema.isMaxLength(10_000)) }),
Schema.Struct({ type: Schema.Literal("press"), key: Key }),
Schema.Struct({
type: Schema.Literal("evaluate"),
script: Schema.String.check(Schema.isMaxLength(100_000)).annotate({
description: "JavaScript to evaluate in the page. The result is JSON-serialized.",
}),
}),
Schema.Struct({
type: Schema.Literal("scroll"),
direction: Direction,
pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)),
}),
]).annotate({ identifier: "Browser.Action" })
export type Action = typeof Action.Type
export interface Command extends Schema.Schema.Type<typeof Command> {}
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({
identifier: "Browser.Command",
})
export const Result = Schema.Union([
Schema.Struct({ type: Schema.Literal("state"), state: State }),
Schema.Struct({
type: Schema.Literal("snapshot"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("evaluate"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("screenshot"),
state: State,
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
}),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Result" })
export type Result = typeof Result.Type
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.Literal("success"), result: Result }),
Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Outcome" })
export type Outcome = typeof Outcome.Type
const attachment = { sessionID: Session.ID, connectionID: Schema.String }
const errors = { unavailable: Schema.Struct({}) }
export const Control = Schema.Union([
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("command"),
connectionID: Schema.String,
requestID: Schema.String,
command: Command,
}),
Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Control" })
export type Control = typeof Control.Type
export const Definition = Rpc.define({
id: "experimental.browser",
methods: {
attach: { input: Schema.Struct(attachment), output: Schema.Void, errors },
state: { input: Schema.Struct({ ...attachment, state: Schema.NullOr(State) }), output: Schema.Void, errors },
result: {
input: Schema.Struct({ ...attachment, requestID: Schema.String, outcome: Outcome }),
output: Schema.Void,
errors,
},
},
events: { control: { schema: Control } },
})
+5 -1
View File
@@ -42,8 +42,12 @@ export const Info = Schema.Struct({
state: State,
}).annotate({ identifier: "Plugin.Info" })
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
})
const Updated = ephemeral({
type: "plugin.updated",
schema: {},
})
export const Event = { Updated, Definitions: inventory(Updated) }
export const Event = { Added, Updated, Definitions: inventory(Added, Updated) }
-1
View File
@@ -39,7 +39,6 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
+1 -3
View File
@@ -15,7 +15,6 @@ const names = [
"protocol",
"client",
"plugin",
"plugin-browser",
"core",
"simulation",
"server",
@@ -164,13 +163,12 @@ export default {
Bun.write(
join(consumer, "boot.mjs"),
`import { Miniflare } from "miniflare"
import { fileURLToPath } from "node:url"
const miniflare = new Miniflare({
compatibilityDate: "2026-07-15",
compatibilityFlags: ["nodejs_compat"],
modules: true,
scriptPath: fileURLToPath(new URL("./dist/worker.js", import.meta.url)),
scriptPath: new URL("./dist/worker.js", import.meta.url).pathname,
durableObjects: { OPENCODE: { className: "OpenCodeDO", useSQLite: true } },
})
-271
View File
@@ -1,271 +0,0 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import plugin from "@opencode-ai/plugin-browser"
import { Browser } from "@opencode-ai/schema/browser"
import { Agent, Rpc } from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
import { Effect, Fiber, Queue, Stream } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 7,
}
const fixture = Effect.gen(function* () {
const directory = yield* tmpdirScoped("opencode-browser-")
const config = path.join(directory.path, "config")
yield* Effect.promise(() => mkdir(config))
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const opencode = yield* OpenCode.create({
database: { path: ":memory:" },
config: {
directory: config,
project: false,
content: JSON.stringify({
plugins: ["-opencode.browser"],
}),
},
models: { fetch: false },
fs: { filewatcher: false, fff: false },
})
const captured = Promise.withResolvers<Tool.Info>()
yield* opencode.plugin({ ...plugin, id: "browser-test" })
yield* opencode.plugin({
id: "browser-test-observer",
effect: (ctx) =>
Effect.gen(function* () {
// Inspect the real tool through the public draft, without replacing its executor.
yield* ctx.tool.transform((draft) => {
const tool = draft.get("browser")
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
})
}).pipe(Effect.orDie),
})
yield* opencode.plugin.list({ location })
const tool = yield* Effect.promise(() => captured.promise)
const session = yield* opencode.sessions.create({ location })
const rpc = opencode.rpc(Browser.Definition)
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
yield* rpc.events.subscribe("control").pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped({ startImmediately: true }),
)
// RPC and native subscriptions share one stream; connected is the readiness barrier.
yield* opencode.events.subscribe().pipe(
Stream.filter((event) => event.type === "server.connected"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
const execute = (action: Browser.Action) =>
tool.execute(action, {
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(crypto.randomUUID()),
progress: () => Effect.void,
})
return {
opencode,
location,
rpc,
execute,
next,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
expect(yield* next).toMatchObject({
type: "rpc.experimental.browser.control",
location,
data: { type: "attached", connectionID },
})
expect(lifetime.pollUnsafe()).toBeUndefined()
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
const pending = yield* execute(action).pipe(Effect.forkScoped)
const event = yield* next.pipe(
Effect.raceFirst(
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
),
)
expect(event.location).toEqual(location)
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command.action).toEqual(action)
return { ...event.data, pending }
}),
}
})
test(
"attachment ownership, cancellation, and plugin unload release pending browser work",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
const stale = yield* host.attach("stale")
// A newer attachment for the same session replaces the previous one.
const attached = yield* host.attach("first")
yield* Fiber.join(stale.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...stale.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
yield* Effect.promise(() => mkdir(other.directory))
yield* host.opencode.plugin.list({ location: other })
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
message: "Session belongs to another location.",
})
expect(
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
yield* host.rpc.state({ ...attached.input, state }, options)
yield* host.rpc.state({ ...attached.input, state: null }, options)
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
message: "Open the browser first.",
})
const cancelled = yield* host.command({ type: "open" })
expect(cancelled.command.generation).toBe(0)
yield* Fiber.interrupt(cancelled.pending)
expect((yield* host.next).data).toEqual({
type: "cancel",
connectionID: attached.input.connectionID,
requestID: cancelled.requestID,
})
// A reply to an interrupted request is harmless while its connection is still attached.
yield* host.rpc.result(
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
options,
)
const closing = yield* host.command({ type: "open" })
yield* Fiber.interrupt(attached.lifetime)
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const replacement = yield* host.attach("replacement")
const pending = yield* host.command({ type: "open" })
expect(pending.connectionID).toBe("replacement")
expect(pending.command.generation).toBe(0)
expect(
yield* host.rpc
.result(
{
...attached.input,
requestID: pending.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
.pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
expect(pending.pending.pollUnsafe()).toBeUndefined()
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void })
yield* host.opencode.plugin.list(options)
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "rpc.unavailable",
})
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
test(
"commands use published state, and RPC results render text and screenshot bytes",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
const attached = yield* host.attach("renderer")
const open = yield* host.command({ type: "open" })
yield* host.rpc.result(
{
...attached.input,
requestID: open.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
yield* host.rpc.state({ ...attached.input, state }, options)
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
expect(navigate.command.generation).toBe(7)
const updated = { ...state, url: "https://example.org/next", generation: 8 }
yield* host.rpc.result(
{
...attached.input,
requestID: navigate.requestID,
outcome: { type: "success", result: { type: "state", state: updated } },
},
options,
)
yield* Fiber.join(navigate.pending)
yield* host.rpc.state({ ...attached.input, state: updated }, options)
const snapshot = yield* host.command({ type: "snapshot" })
expect(snapshot.command.generation).toBe(8)
yield* host.rpc.result(
{
...attached.input,
requestID: snapshot.requestID,
outcome: {
type: "success",
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
},
},
options,
)
const text = yield* Fiber.join(snapshot.pending)
expect(text.metadata).toEqual({ url: updated.url })
expect(text.content).toContain('encoding="json"')
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
const screenshot = yield* host.command({ type: "screenshot" })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: screenshot.requestID,
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
},
options,
)
expect(yield* Fiber.join(screenshot.pending)).toEqual({
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
],
metadata: { url: updated.url },
})
const failure = yield* host.command({ type: "snapshot" })
yield* host.rpc.result(
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
options,
)
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
+5 -7
View File
@@ -14,7 +14,7 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
return output === undefined ? {} : { output }
}).pipe(
Effect.mapError((error) =>
error.type === "rpc.invalid_output"
error.type === "rpc.invalid_output" || error.type === "rpc.internal"
? new RpcInternalError({ type: error.type, message: error.message })
: new RpcError({
type: error.type,
@@ -22,12 +22,10 @@ export const RpcHandler = HttpApiBuilder.group(Api, "server.rpc", (handlers) =>
...(error.data === undefined ? {} : { data: error.data }),
}),
),
Effect.catchDefect((error) =>
Effect.fail(
new RpcInternalError({
type: "rpc.internal",
message: error instanceof Error ? error.message : "RPC call failed",
}),
// Core already converts handler defects; this net catches anything else without echoing its text.
Effect.catchDefect((defect) =>
Effect.logError("rpc call failed", { rpc: params.rpcID, method: params.method, defect }).pipe(
Effect.andThen(Effect.fail(new RpcInternalError({ type: "rpc.internal", message: "RPC call failed" }))),
),
),
),
+371
View File
@@ -0,0 +1,371 @@
import { expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { fromPromise } from "@opencode-ai/plugin/promise/adapter"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Rpc } from "@opencode-ai/schema/rpc"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Context, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
type RpcEvent = Extract<OpenCodeEvent, { type: `rpc.${string}` }>
const authorization = `Basic ${btoa("opencode:secret")}`
const fixture = Effect.fn(function* (plugins: readonly Parameters<SdkPlugins.Interface["register"]>[0][]) {
const tmp = yield* tmpdirScoped("opencode-rpc-server-")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const config = path.join(tmp.path, "config")
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
config: { directory: config, project: false, content: "{}" },
fs: { filewatcher: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* Effect.forEach(plugins, (plugin) => sdk.register(plugin))
const locations = Context.get(context, LocationServiceMap.Service)
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
return {
first,
second,
handler,
boot: (directory: string) =>
Plugin.awaitActivation.pipe(
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
),
call: (
route: string,
body: unknown = {},
options: { directory?: string; headers?: Record<string, string>; signal?: AbortSignal } = {},
) =>
Effect.promise(() => {
const url = new URL(`/api/rpc/${route}`, "http://opencode.local")
if (options.directory) url.searchParams.set("location[directory]", options.directory)
return handler(
new Request(url, {
method: "POST",
headers: { authorization, "content-type": "application/json", ...options.headers },
body: JSON.stringify(body),
signal: options.signal,
}),
)
}),
}
})
it.live("dispatches RPC wrappers with query, header and default locations and generic failures", () =>
Effect.gen(function* () {
const Echo = Rpc.define({
id: "transport.echo",
methods: {
echo: { input: Schema.String, output: Schema.String },
json: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
fail: {
input: Schema.Undefined,
output: Schema.String,
errors: { rejected: Schema.Struct({ reason: Schema.String }) },
},
defect: { input: Schema.Undefined, output: Schema.String },
undeclared: { input: Schema.Undefined, output: Schema.String },
invalid: { input: Schema.Undefined, output: { type: "string" } },
},
events: {},
})
const server = yield* fixture([
define({
id: "transport-implementer",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.rpc.register(Echo, {
echo: (input) => Effect.succeed(`${ctx.location.directory}:${input}`),
json: (input) => Effect.succeed(input),
empty: () => Effect.succeed(undefined),
fail: (_input, context) =>
Effect.fail(context.error("rejected", "handler failed", { reason: "declared" })),
defect: () => Effect.die(new Error("secret handler detail")),
// @ts-expect-error undeclared error names are rejected statically; the runtime contract is under test
undeclared: (_input, context) => Effect.fail(context.error("surprise", "undeclared failure")),
invalid: () => Effect.succeed(123),
})
}).pipe(Effect.orDie),
}),
])
yield* server.boot(server.first)
yield* server.boot(server.second)
yield* server.boot(process.cwd())
const selected = yield* server.call(
"transport.echo/echo",
{ input: "selected" },
{
directory: server.first,
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
},
)
expect(selected.status).toBe(200)
expect(yield* Effect.promise(() => selected.json())).toEqual({ output: `${server.first}:selected` })
const header = yield* server.call(
"transport.echo/echo",
{ input: "header" },
{
headers: { "x-opencode-directory": encodeURIComponent(server.second) },
},
)
expect(yield* Effect.promise(() => header.json())).toEqual({ output: `${server.second}:header` })
const fallback = yield* server.call("transport.echo/echo", { input: "default" })
expect(yield* Effect.promise(() => fallback.json())).toEqual({ output: `${process.cwd()}:default` })
const empty = yield* server.call("transport.echo/empty")
expect(empty.status).toBe(200)
expect(yield* Effect.promise(() => empty.json())).toEqual({})
yield* Effect.forEach([null, false, 42, ["array"], { location: "ordinary input" }], (input) =>
Effect.gen(function* () {
const response = yield* server.call("transport.echo/json", { input })
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toEqual({ output: input })
}),
)
const denied = yield* server.call("transport.echo/empty", {}, { headers: { authorization: "" } })
expect(denied.status).toBe(401)
yield* Effect.forEach(
[
{
route: "missing/echo",
body: {},
error: { type: "rpc.unavailable", message: "RPC is unavailable: missing" },
},
{
route: "transport.echo/missing",
body: {},
error: { type: "rpc.method_not_found", message: "Unknown RPC method: transport.echo.missing" },
},
{
route: "transport.echo/fail",
body: {},
error: { type: "rejected", message: "handler failed", data: { reason: "declared" } },
},
{ route: "transport.echo/echo", body: { input: 123 }, error: { type: "rpc.invalid_input" } },
],
(item) =>
Effect.gen(function* () {
const response = yield* server.call(item.route, item.body)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "RpcError",
message: expect.any(String),
...item.error,
})
}),
)
// Handler exception text never reaches HTTP clients; the fixed message matches the in-process contract.
const defect = yield* server.call("transport.echo/defect")
expect(defect.status).toBe(500)
expect(yield* Effect.promise(() => defect.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "RPC call failed",
})
const undeclared = yield* server.call("transport.echo/undeclared")
expect(undeclared.status).toBe(500)
expect(yield* Effect.promise(() => undeclared.json())).toEqual({
_tag: "RpcInternalError",
type: "rpc.internal",
message: "Undeclared RPC error: surprise",
})
const invalid = yield* server.call("transport.echo/invalid")
expect(invalid.status).toBe(500)
expect(yield* Effect.promise(() => invalid.json())).toMatchObject({
_tag: "RpcInternalError",
type: "rpc.invalid_output",
message: expect.any(String),
})
const malformed = yield* server.call("transport.echo/echo", "not a wrapper")
expect(malformed.status).toBe(400)
expect(yield* Effect.promise(() => malformed.json())).toMatchObject({
_tag: "InvalidRequestError",
message: expect.any(String),
})
}),
)
it.live("request cancellation interrupts Effect RPC handlers and signals Promise RPC handlers", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
const promiseStarted = Promise.withResolvers<void>()
const promiseStopped = Promise.withResolvers<void>()
const Blocking = Rpc.define({
id: "blocking",
methods: { wait: { input: Schema.Undefined, output: Schema.Undefined } },
events: {},
})
const PromiseBlocking = Rpc.define({
id: "promise-blocking",
methods: { wait: { input: { type: "null" }, output: { type: "null" } } },
events: {},
})
const server = yield* fixture([
define({
id: "effect-blocking",
effect: (ctx) =>
ctx.rpc
.register(Blocking, {
wait: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.ensuring(Deferred.succeed(stopped, undefined)),
),
})
.pipe(Effect.asVoid, Effect.orDie),
}),
fromPromise({
id: "promise-blocking",
async setup(ctx) {
await ctx.rpc.register(PromiseBlocking, {
wait: (_input, call) =>
new Promise<null>((resolve) => {
promiseStarted.resolve()
call.signal.addEventListener(
"abort",
() => {
promiseStopped.resolve()
resolve(null)
},
{ once: true },
)
}),
})
},
}),
])
yield* server.boot(server.first)
const controller = new AbortController()
const pending = yield* server
.call(
"blocking/wait",
{},
{
directory: server.first,
signal: controller.signal,
},
)
.pipe(Effect.forkScoped)
yield* Deferred.await(started)
controller.abort()
yield* Deferred.await(stopped)
expect((yield* Fiber.join(pending)).status).not.toBe(400)
const promiseController = new AbortController()
const promisePending = yield* server
.call(
"promise-blocking/wait",
{ input: null },
{
directory: server.first,
signal: promiseController.signal,
},
)
.pipe(Effect.forkScoped)
yield* Effect.promise(() => promiseStarted.promise)
promiseController.abort()
yield* Effect.promise(() => promiseStopped.promise)
expect((yield* Fiber.join(promisePending)).status).not.toBe(400)
}),
)
it.live(
"public SSE and generic native plugin subscriptions receive RPC events across locations",
() =>
Effect.gen(function* () {
const Updates = Rpc.define({
id: "updates",
methods: { emit: { input: Schema.String, output: Schema.Undefined } },
events: { updated: { schema: Schema.Struct({ text: Schema.String }) } },
})
const received: RpcEvent[] = []
const observed = yield* Deferred.make<void>()
const server = yield* fixture([
define({
id: "updates-implementer",
effect: (ctx) =>
Effect.gen(function* () {
const registration = yield* ctx.rpc.register(Updates, {
emit: (input): Effect.Effect<undefined> =>
registration.events.emit("updated", { text: input }).pipe(Effect.as(undefined), Effect.orDie),
})
}).pipe(Effect.orDie),
}),
define({
id: "native-observer",
effect: (ctx) =>
Effect.gen(function* () {
// One observer instance should see both locations, just like the public native stream.
if (path.basename(ctx.location.directory) !== "first") return
yield* ctx.event.subscribe().pipe(
Stream.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
Stream.take(2),
Stream.runForEach((event) => Effect.sync(() => received.push(event))),
Effect.andThen(Deferred.succeed(observed, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
}).pipe(Effect.orDie),
}),
])
yield* server.boot(server.first)
yield* server.boot(server.second)
const response = yield* Effect.promise(() =>
server.handler(
new Request("http://opencode.local/api/event", {
headers: { authorization, "x-opencode-directory": encodeURIComponent(server.first) },
}),
),
)
expect(response.status).toBe(200)
if (!response.body) throw new Error("Expected an SSE body")
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel()))
expect((yield* Effect.promise(() => reader.read())).value).toContain('"type":"server.connected"')
const first = yield* server.call("updates/emit", { input: "first" }, { directory: server.first })
const second = yield* server.call("updates/emit", { input: "second" }, { directory: server.second })
expect(first.status).toBe(200)
expect(second.status).toBe(200)
const events: RpcEvent[] = []
while (events.length < 2) {
const chunk = yield* Effect.promise(() => reader.read())
if (chunk.done) throw new Error("Event stream closed before RPC events arrived")
events.push(
...chunk.value
.split("\n\n")
.filter((frame) => frame.startsWith("data: "))
.map((frame) => Schema.decodeUnknownSync(Schema.fromJsonString(OpenCodeEvent))(frame.slice(6)))
.filter((event): event is RpcEvent => event.type === "rpc.updates.updated"),
)
}
yield* Deferred.await(observed)
expect(events).toMatchObject([
{
type: "rpc.updates.updated",
location: { directory: server.first },
data: { text: "first" },
},
{
type: "rpc.updates.updated",
location: { directory: server.second },
data: { text: "second" },
},
])
expect(received).toEqual(events)
}),
15_000,
)
@@ -320,11 +320,6 @@ export function getToolInfo(
title: i18n.t("ui.tool.grep"),
subtitle: typeof input.pattern === "string" ? input.pattern : undefined,
}
case "browser":
return {
icon: "window-cursor",
title: i18n.t("ui.tool.browser"),
}
case "webfetch":
return {
icon: "window-cursor",
+2 -2
View File
@@ -88,7 +88,7 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { localPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
@@ -188,7 +188,7 @@ export type TuiInput = {
updater?: {
apply: (version: string) => Promise<void>
}
packages: PackageSource
packages: PackageResolver
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
| {
@@ -2,7 +2,6 @@ import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { Spinner } from "../../component/spinner"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
@@ -32,17 +31,12 @@ export function PluginsDialog(props: {
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [showInternal, setShowInternal] = createSignal(false)
const [pending, setPending] = createSignal<readonly string[]>([])
const [server, { refetch, mutate }] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
const updating = (entry: Entry) =>
pending().includes(entry.key) ||
(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true)
const updatable = (entry: Entry | undefined) => entry !== undefined && outdated(entry) && !updating(entry)
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@@ -92,16 +86,15 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: updating(entry) ? "updating" : footer(entry),
footer: footer(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: outdated(entry)
? props.context.theme.text.feedback.info.default
: props.context.theme.text.subdued,
gutter: updating(entry)
? (color) => <Spinner color={color} />
: status(entry) === "failed"
gutter:
status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}>x</text>
: undefined,
}),
@@ -138,22 +131,20 @@ export function PluginsDialog(props: {
}
const update = (entry: Entry | undefined) => {
if (entry?.runtime !== "server" || entry.plugin.source.type !== "package" || !updatable(entry)) return
const location = props.context.location ?? props.context.data.location.default()
setPending((keys) => [...keys, entry.key])
props.context.client.plugin
.update({
location,
location: props.context.location ?? props.context.data.location.default(),
targets: [entry.plugin.source.target],
})
.then(() => props.context.client.plugin.awaitActivation({ location }))
.then(() => refetch())
.then(() =>
props.context.ui.toast.show({ variant: "success", message: `Updated plugin ${label(entry, props.context)}` }),
)
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
})
})
.finally(() => setPending((keys) => keys.filter((key) => key !== entry.key)))
}
// The server only re-checks package sources on startup and then caches the
// result for a day, so a merge pushed after launch stays invisible until the
@@ -296,13 +287,21 @@ function outdated(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
}
function updating(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true
}
function updatable(entry: Entry | undefined) {
return entry !== undefined && outdated(entry) && !updating(entry)
}
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),
...(outdated(entry) ? ["update available"] : []),
...(updating(entry) ? ["updating"] : outdated(entry) ? ["update available"] : []),
]
return details.length ? details.join(", ") : undefined
}
+25 -17
View File
@@ -15,10 +15,9 @@ import {
} from "solid-js"
import path from "path"
import { readFile, stat } from "fs/promises"
import { fileURLToPath } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import type { Page } from "@opencode-ai/plugin/tui/context"
import { Hash } from "@opencode-ai/util/hash"
import { Host } from "@opencode-ai/plugin/host"
import { resolveSlots, type Claim } from "./structure"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import { isDeepEqual } from "remeda"
@@ -31,11 +30,11 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiEntrypoint } from "./discovery"
import { isMissingPath } from "../util/config-directories"
export interface PackageSource {
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
}
type State =
@@ -94,7 +93,7 @@ export function combineMarkdownRenderers(
return createMarkdownCodeBlockRenderer(renderers)
}
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
@@ -266,17 +265,19 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [
...(await discoverPluginTargets(props.directories)).map((entry) => ({
...(await discoverTuiPlugins(props.directories)).map((entry) => ({
entry,
install: true,
optional: true,
server: false,
discovered: true,
})),
...serverPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
optional: true,
server: true,
discovered: false,
})),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false, discovered: false })),
]
// Resolve: fold entries into one desired generation. A source that fails
@@ -303,6 +304,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
const local = localSource(target, directory)
if (
local &&
!source.discovered &&
(await stat(local).then(
(info) => info.isFile(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
@@ -322,7 +324,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
}),
)
if (resolved.status === "unsupported") {
if (source.optional) continue
if (source.server) continue
failures.push({ target, status: "unsupported" })
continue
}
@@ -596,7 +598,7 @@ async function resolvePlugin(
local: URL | undefined,
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageSource,
packages: PackageResolver,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
) {
@@ -604,8 +606,7 @@ async function resolvePlugin(
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const target = local ? { directory: fileURLToPath(local) } : await packages.prepare(spec, install)
const entrypoint = Host.resolve(target).tui
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
@@ -614,7 +615,7 @@ async function resolvePlugin(
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod = await Host.load(version)
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
// In-place saves can change the file between hashing and import. Retry
@@ -624,8 +625,7 @@ async function resolvePlugin(
continue
}
}
if (typeof mod !== "object" || mod === null || !("default" in mod) || !isPlugin(mod.default))
throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
}
@@ -671,6 +671,14 @@ function snapshotOptions(options: Registration["options"]) {
return options ? structuredClone(unwrap(options)) : undefined
}
async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
if (!info.isDirectory()) return
const entrypoint = await tuiEntrypoint(fileURLToPath(url))
return entrypoint ? pathToFileURL(entrypoint).href : undefined
}
function isPlugin(value: unknown): value is Plugin.Definition {
return (
typeof value === "object" &&
+12 -2
View File
@@ -3,6 +3,8 @@ import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
const extensions = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]
export async function localPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
@@ -19,7 +21,7 @@ export async function localPluginDirectories(cwd: string, configDirectory: strin
return directories.filter((_, index) => exists[index]).map((directory) => path.join(directory, "plugins"))
}
export async function discoverPluginTargets(directories: string[]) {
export async function discoverTuiPlugins(directories: string[]) {
return (
await Promise.all(
directories.map(async (directory) => {
@@ -41,7 +43,7 @@ export async function discoverPluginTargets(directories: string[]) {
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
))
if (!isDirectory) return undefined
return plugin
return tuiEntrypoint(plugin)
}),
)
).filter((entry): entry is string => entry !== undefined)
@@ -50,6 +52,14 @@ export async function discoverPluginTargets(directories: string[]) {
).flat()
}
export async function tuiEntrypoint(directory: string) {
const files = await readdir(directory, { withFileTypes: true })
const names = new Set(files.filter((file) => file.isFile() || file.isSymbolicLink()).map((file) => file.name))
if (!extensions.some((extension) => names.has("index" + extension))) return undefined
const tui = extensions.find((extension) => names.has("tui" + extension))
return tui ? path.join(directory, "tui" + tui) : undefined
}
export function localSource(spec: string, directory: string) {
if (spec.startsWith("file://")) return new URL(spec)
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
+6 -6
View File
@@ -239,7 +239,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -315,7 +315,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -389,7 +389,7 @@ test("session title generated while an untitled session is loading remains visib
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -775,7 +775,7 @@ test("session startup prompt is submitted exactly once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -1309,7 +1309,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -1395,7 +1395,7 @@ async function createAppFixture(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
+2 -2
View File
@@ -59,7 +59,7 @@ test.each([70, 120])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -184,7 +184,7 @@ test.each(["first", "second"])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
+1 -1
View File
@@ -103,7 +103,7 @@ test.each([
}),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "ses_test" },
log: () => {},
@@ -79,7 +79,7 @@ test.each([80, 120, 180])("session wheel scrolling preserves pane focus at width
get: async () => ({ animations: false, session: { terminal: true }, tabs: { enabled: false } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
+1 -1
View File
@@ -41,7 +41,7 @@ test("stats shows only this year and returns after errors or success", async ()
get: async () => ({ animations: false, tabs: { enabled: false } }),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
-1
View File
@@ -165,7 +165,6 @@ const source = {
"ui.tool.glob": "Glob",
"ui.tool.grep": "Grep",
"ui.tool.task": "Task",
"ui.tool.browser": "Browser",
"ui.tool.webfetch": "Webfetch",
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
@@ -49,7 +49,7 @@ export const additionalIcons = {
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
"magnifying-glass-menu": `<path d="M2.08325 10.0002H4.58325M2.08325 5.41683H5.41659M2.08325 14.5835H5.41659M16.4583 13.9585L18.7499 16.2502M17.9166 10.0002C17.9166 12.9917 15.4915 15.4168 12.4999 15.4168C9.50838 15.4168 7.08325 12.9917 7.08325 10.0002C7.08325 7.00862 9.50838 4.5835 12.4999 4.5835C15.4915 4.5835 17.9166 7.00862 17.9166 10.0002Z" stroke="currentColor" stroke-linecap="square"/>`,
"window-cursor": `<path d="M17.5 10.4167V3.75H2.5V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-linecap="square"/><path d="M5.41675 6.125C5.71576 6.12518 5.95777 6.36795 5.95777 6.66699C5.95759 6.96589 5.71565 7.20783 5.41675 7.20801C5.11771 7.20801 4.87494 6.966 4.87476 6.66699C4.87476 6.36784 5.1176 6.125 5.41675 6.125ZM8.33276 6.125C8.63191 6.125 8.87476 6.36784 8.87476 6.66699C8.87458 6.966 8.63181 7.20801 8.33276 7.20801C8.03386 7.20783 7.79192 6.96589 7.79175 6.66699C7.79175 6.36795 8.03376 6.12518 8.33276 6.125ZM11.2498 6.125C11.5489 6.125 11.7917 6.36784 11.7917 6.66699C11.7916 6.966 11.5488 7.20801 11.2498 7.20801C10.9507 7.20801 10.7079 6.966 10.7078 6.66699C10.7078 6.36784 10.9506 6.125 11.2498 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
"window-cursor": `<path d="M17.9166 10.4167V3.75H2.08325V17.0833H10.4166M17.9166 13.5897L11.6666 11.6667L13.5897 17.9167L15.032 15.0321L17.9166 13.5897Z" stroke="currentColor" stroke-width="1.07143" stroke-linecap="square"/><path d="M5.00024 6.125C5.29925 6.12518 5.54126 6.36795 5.54126 6.66699C5.54108 6.96589 5.29914 7.20783 5.00024 7.20801C4.7012 7.20801 4.45843 6.966 4.45825 6.66699C4.45825 6.36784 4.70109 6.125 5.00024 6.125ZM7.91626 6.125C8.21541 6.125 8.45825 6.36784 8.45825 6.66699C8.45808 6.966 8.21531 7.20801 7.91626 7.20801C7.61736 7.20783 7.37542 6.96589 7.37524 6.66699C7.37524 6.36795 7.61726 6.12518 7.91626 6.125ZM10.8333 6.125C11.1324 6.125 11.3752 6.36784 11.3752 6.66699C11.3751 6.966 11.1323 7.20801 10.8333 7.20801C10.5342 7.20801 10.2914 6.966 10.2913 6.66699C10.2913 6.36784 10.5341 6.125 10.8333 6.125Z" fill="currentColor" stroke="currentColor" stroke-width="0.25" stroke-linecap="square"/>`,
task: `<path d="M9.99992 2.0835V17.9168M7.08325 3.75016H2.08325V16.2502H7.08325M12.9166 16.2502H17.9166V3.75016H12.9166" stroke="currentColor" stroke-linecap="square"/>`,
subagent: `<path d="M4.5 5C4.5 4.72386 4.72386 4.5 5 4.5H11C11.2761 4.5 11.5 4.72386 11.5 5V11C11.5 11.2761 11.2761 11.5 11 11.5H5C4.72386 11.5 4.5 11.2761 4.5 11V5Z" fill="currentColor"/><path d="M13.5 2C13.7761 2 14 2.22386 14 2.5V13.5C14 13.7761 13.7761 14 13.5 14H2.5C2.22386 14 2 13.7761 2 13.5V2.5C2 2.22386 2.22386 2 2.5 2H13.5ZM3 13H13V3H3V13Z" fill="currentColor"/>`,
stop: `<rect x="5" y="5" width="10" height="10" fill="currentColor"/>`,
+2 -1
View File
@@ -574,7 +574,8 @@
}
.tab-fileicon-color,
.tab-fileicon-mono {
.tab-fileicon-mono,
[data-slot="icon-svg"] {
margin-inline-start: -2px;
}
-20
View File
@@ -16,26 +16,6 @@ The `/admin*` route must be protected by a Cloudflare Access self-hosted applica
The Worker has `workers_dev` and preview URLs disabled so the custom hostname is its only public entry point.
## Request logging
Every request reaching the Worker emits an unsampled event at request start to the shared production
Cloudflare lake stream through the `EVENTS` Pipelines binding. Events use
`source: "update"`, `type: "request"`, an ISO `timestamp`, and a `payload` containing
the method, path, `useragent`, `ip` (from Cloudflare's `CF-Connecting-IP` header),
country, and Cloudflare colo. Query strings, request bodies, cookies, and authorization
headers are not included. Response status and duration are not recorded.
Delivery runs in `waitUntil` without delaying the response. Delivery failures are
logged but do not fail requests or retry; this is not lossless audit logging.
Requests blocked before reaching the Worker are not recorded.
The stream ID in `wrangler.jsonc` comes from the `lake.stream` output of the
`anomalyco/platform/production` Pulumi stack. Update the binding if that stream is
replaced. The stream is shared across release channels because the update service
has a single public deployment.
## Publishing
GitHub Actions publishes artifacts through `POST /api/publish` using a short-lived OIDC token with audience `https://update.opencode.ai`. The Worker accepts only tokens signed by GitHub for repository ID `975734319`, owner ID `66570915`, and `.github/workflows/publish.yml` on configured publishing refs.
Apply migrations and deploy from this directory:
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test"
import worker, { channelsForRef, resolveChannel, validGitHubClaims } from "./index"
const claims = {
repository: "anomalyco/opencode",
repository_id: "975734319",
repository_owner_id: "66570915",
workflow_ref: "anomalyco/opencode/.github/workflows/publish.yml@refs/heads/dev",
ref: "refs/heads/dev",
sha: "abc123",
run_id: "123",
run_attempt: "1",
actor: "opencode-agent",
}
describe("GitHub publish authorization", () => {
test("allows the publish workflow from the repository", () => {
expect(validGitHubClaims(claims)).toBe(true)
expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"])
})
test("maps V2 development to the dev channel", () => {
expect(channelsForRef("refs/heads/v2")).toEqual(["dev"])
})
test("rejects another repository or workflow", () => {
expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false)
expect(
validGitHubClaims({ ...claims, workflow_ref: "anomalyco/opencode/.github/workflows/other.yml@refs/heads/dev" }),
).toBe(false)
})
test("rejects unconfigured refs", () => {
const ref = "refs/heads/untrusted"
expect(
validGitHubClaims({ ...claims, ref, workflow_ref: `anomalyco/opencode/.github/workflows/publish.yml@${ref}` }),
).toBe(false)
})
})
test("routes the retired next channel to beta", () => {
expect(resolveChannel("next")).toBe("beta")
expect(resolveChannel("dev")).toBe("dev")
})
const artifact = {
channel: "beta",
name: "opencode",
distribution: "darwin-arm64",
version: "1.0.0",
metadata: "{}",
active: 1,
time_created: 1,
time_updated: 2,
}
test.each([
["/api/next", ["beta"], { channel: "beta", artifacts: [{ ...artifact, metadata: {}, active: true }] }],
[
"/api/next/opencode",
["beta", "opencode"],
{ channel: "beta", name: "opencode", artifacts: [{ ...artifact, metadata: {}, active: true }] },
],
[
"/api/next/opencode/darwin-arm64",
["beta", "opencode", "darwin-arm64"],
{ ...artifact, metadata: {}, active: true },
],
])("routes GET %s", async (path, expectedBindings, expectedBody) => {
const bindings: unknown[][] = []
const statement = {
bind(...values: unknown[]) {
bindings.push(values)
return statement
},
async all() {
return { results: [artifact] }
},
async first() {
return artifact
},
}
const db = {
prepare() {
return statement
},
} as unknown as D1Database
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
expect(response.status).toBe(200)
expect(await response.text()).toBe(JSON.stringify(expectedBody))
expect(bindings).toEqual([expectedBindings])
})
test.each(["/api", "/v1/dev", "/api/dev/opencode/darwin-arm64/extra", "/api/dev/opencode/darwin$arm64"])(
"returns 404 for GET %s",
async (path) => {
const db = {
prepare() {
throw new Error("Invalid routes must not query the database")
},
} as unknown as D1Database
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
expect(response.status).toBe(404)
expect(await response.text()).toBe("Not found")
},
)
+1 -20
View File
@@ -1,9 +1,7 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"
import type { Pipeline } from "cloudflare:pipelines"
interface Env {
DB: D1Database
EVENTS: Pipeline
}
type ArtifactRow = {
@@ -33,25 +31,8 @@ const audience = "https://update.opencode.ai"
const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks"))
export default {
async fetch(request: Request, env: Env, ctx: Pick<ExecutionContext, "waitUntil">): Promise<Response> {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
ctx.waitUntil(
env.EVENTS.send([
{
source: "update",
type: "request",
timestamp: new Date().toISOString(),
payload: {
method: request.method,
path: url.pathname,
useragent: request.headers.get("user-agent"),
ip: request.headers.get("cf-connecting-ip"),
cf_country: request.cf?.country,
cf_colo: request.cf?.colo,
},
},
]).catch(() => console.error("Failed to send update request event to the data lake")),
)
if (url.pathname === "/") return json({ service: "opencode-updates" })
if (url.pathname === "/admin" && request.method === "GET") return admin(request, env)
-7
View File
@@ -5,13 +5,6 @@
"compatibility_date": "2026-07-21",
"workers_dev": false,
"preview_urls": false,
"pipelines": [
{
"binding": "EVENTS",
// Shared lake stream from anomalyco/platform/production.
"stream": "251a89241c3a461c9007f6b6f345ed8b",
},
],
"routes": [
{
"pattern": "update.opencode.ai",
+2 -1
View File
@@ -54,7 +54,8 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1"
"pacote": "21.5.1",
"resolve.exports": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+79 -40
View File
@@ -11,6 +11,7 @@ import { filesystem } from "./effect/app-node-platform.js"
import { LayerNode } from "./effect/layer-node.js"
import { makeRuntime } from "./effect/runtime.js"
import { NpmConfig } from "./npm-config.js"
import { resolveModule } from "#runtime-import"
export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
@@ -18,18 +19,24 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
cause: Schema.optional(Schema.Defect()),
}) {}
export interface Package {
export interface EntryPoint {
readonly directory: string
readonly name: string
readonly entrypoint?: string
readonly version?: string
readonly revision?: string
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<Package, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string) => Effect.Effect<Package>
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly check: (pkg: string) => Effect.Effect<boolean, InstallFailedError>
readonly update: (pkg: string) => Effect.Effect<Package, InstallFailedError | EffectFlock.LockError>
readonly update: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -107,6 +114,22 @@ function gitSlug(pkg: string) {
)
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const entrypoint = subpaths
.map((subpath) => {
try {
return resolveModule([name, subpath].filter(Boolean).join("/"), dir)
} catch {
return undefined
}
})
.find((entrypoint) => entrypoint !== undefined)
return {
directory: dir,
entrypoint,
}
}
interface ArboristNode {
name: string
path: string
@@ -192,7 +215,13 @@ const layer = Layer.effect(
if (revision) return revision
}
})
const metadata = Effect.fnUntraced(function* (root: string, name: string, dir: string, target: Target | undefined) {
const entry = Effect.fnUntraced(function* (
root: string,
name: string,
dir: string,
target: Target | undefined,
subpaths?: readonly string[],
) {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
@@ -200,8 +229,7 @@ const layer = Layer.effect(
const revision = target ? (yield* installedRevision(root, name, target)) ?? manifestVersion : undefined
const version = target?.type === "git" ? revision : manifestVersion
return {
directory: dir,
name,
...resolveEntryPoint(name, dir, subpaths),
...(version ? { version } : {}),
...(revision ? { revision } : {}),
}
@@ -246,47 +274,43 @@ const layer = Layer.effect(
pkg: string,
target: Target | undefined,
dir: string,
subpaths: readonly string[] | undefined,
update: boolean,
) {
yield* flock.acquire(`npm-install:${dir}`)
const active = yield* current(dir)
const name = yield* installedName(pkg, active ?? dir, target)
if (active && !update && (yield* afs.existsSafe(path.join(active, "node_modules", name)))) {
return yield* metadata(active, name, path.join(active, "node_modules", name), target)
return yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
}
yield* mkdir(dir)
const startedAt = yield* Clock.currentTimeMillis
// Arborist keys lockfile entries relative to the root's real path. When the cache
// directory is reached through a symlink (macOS `/var` → `/private/var`, a linked
// XDG cache), the keys become `../../…` paths that installedRevision never finds,
// so Git checks report "not installed" and updates go undetected. Stage under the
// resolved directory so the root path and real path agree.
const root = yield* fs.realPath(dir).pipe(Effect.mapError((cause) => new InstallFailedError({ dir, cause })))
const staging = path.join(root, `.staging-${startedAt}-${randomUUID()}`)
const staging = path.join(dir, `.staging-${startedAt}-${randomUUID()}`)
const staged = yield* Effect.gen(function* () {
const tree = yield* reify({ dir: staging, config: dir, add: [pkg], update })
const installed = tree.edgesOut.values().next().value?.to
const installedNameValue = installed?.name ?? (yield* installedName(pkg, staging, target))
const result = yield* metadata(
const result = yield* entry(
staging,
installedNameValue,
installed?.path ?? path.join(staging, "node_modules", installedNameValue),
target,
// Resolve installed entrypoints after rename so Bun cannot hold the staging directory open on Windows.
installed ? [] : subpaths,
)
if (!installed && !(yield* afs.isDir(result.directory)))
return yield* new InstallFailedError({ add: [pkg], dir: staging })
if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging })
const links =
process.platform === "win32"
? Array.from(tree.inventory.values()).filter(
(node) => node.isLink && FSUtil.contains(staging, node.path) && FSUtil.contains(staging, node.realpath),
)
: []
return { result, links }
return { name: installedNameValue, result, links }
}).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
if (active) {
const activeEntry = yield* metadata(active, name, path.join(active, "node_modules", name), target)
const activeEntry = yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
if (activeEntry.revision && activeEntry.revision === staged.result.revision) {
yield* remove(staging, dir)
return activeEntry
@@ -313,7 +337,13 @@ const layer = Layer.effect(
).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
}
yield* rename(staging, generation, dir)
return { ...staged.result, directory: path.join(generation, "node_modules", staged.result.name) }
return yield* entry(
generation,
staged.name,
path.join(generation, "node_modules", staged.name),
target,
subpaths,
)
})
const collect = Effect.fnUntraced(function* (dir: string) {
@@ -337,20 +367,26 @@ const layer = Layer.effect(
)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const add = Effect.fn("Npm.add")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
return yield* install(pkg, target, dir, false)
return yield* install(pkg, target, dir, options?.subpaths, false)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (pkg: string) {
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const target = yield* Effect.promise(() => parse(pkg))
const root = directory(pkg, target)
const generation = yield* current(root)
const name = yield* installedName(pkg, generation ?? root, target)
const dir = path.join(generation ?? root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir, name }
return yield* metadata(generation ?? root, name, dir, target)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return yield* entry(generation ?? root, name, dir, target, options?.subpaths)
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
@@ -379,19 +415,22 @@ const layer = Layer.effect(
return installed !== available
})
const update = Effect.fn("Npm.update")(function* (pkg: string) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
if (!target)
return yield* new InstallFailedError({
dir,
cause: new Error("Package updates only support registry and Git package specs"),
})
if (!target.mutable) return yield* add(pkg)
const installed = yield* install(pkg, target, dir, true)
yield* collect(dir)
return installed
}, Effect.scoped)
const update = Effect.fn("Npm.update")(
function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
if (!target)
return yield* new InstallFailedError({
dir,
cause: new Error("Package updates only support registry and Git package specs"),
})
if (!target.mutable) return yield* add(pkg, options)
const installed = yield* install(pkg, target, dir, options?.subpaths, true)
yield* collect(dir)
return installed
},
Effect.scoped,
)
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const target = yield* Effect.promise(() => parse(pkg))
+1 -4
View File
@@ -1,10 +1,7 @@
import { pathToFileURL } from "node:url"
export function importModule(specifier: string) {
return import(specifier) as Promise<unknown>
}
export function resolveModule(specifier: string, directory: string) {
const resolved = Bun.resolveSync(specifier, directory)
return resolved.startsWith("node:") ? resolved : pathToFileURL(resolved).href
return import.meta.resolve(specifier, directory)
}
+20 -42
View File
@@ -1,8 +1,20 @@
import { Script, constants } from "node:vm"
import { registerHooks } from "node:module"
import { statSync } from "node:fs"
import { createRequire, registerHooks } from "node:module"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { resolve, type Package } from "resolve.exports"
let conditions: readonly string[] = []
const conditionHooks = registerHooks({
resolve(specifier, context, nextResolve) {
conditions = context.conditions
return nextResolve(specifier, context)
},
})
await new Script('import("node:module")', {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}).runInThisContext()
conditionHooks.deregister()
export async function importModule(specifier: string) {
const imported = (await new Script(`import(${JSON.stringify(specifier)})`, {
@@ -18,44 +30,10 @@ export async function importModule(specifier: string) {
}
export function resolveModule(specifier: string, directory: string) {
// Node only accepts import.meta.resolve's parent URL behind an experimental
// flag. Scope this synchronous resolution to the caller's package directory
// through the supported resolver hook instead.
const hook = registerHooks({
resolve(specifier, context, nextResolve) {
return nextResolve(specifier, { ...context, parentURL: pathToFileURL(path.join(directory, "package.json")).href })
},
})
try {
const resolve = (target: string) => {
const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target)
if (resolved.startsWith("file:")) statSync(new URL(resolved))
return resolved
}
try {
return resolve(specifier)
} catch (error) {
if (path.extname(specifier) || !missing(error)) throw error
// Node does not infer extensions for local files or legacy package
// subpaths. Resolve each candidate natively so package exports still apply.
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]) {
try {
return resolve(specifier + extension)
} catch (cause) {
if (!missing(cause)) throw cause
}
}
throw error
}
} finally {
hook.deregister()
}
}
function missing(error: unknown) {
return (
error instanceof Error &&
"code" in error &&
["ENOENT", "ENOTDIR", "ERR_MODULE_NOT_FOUND"].includes(String(error.code))
)
const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package
const target = resolve(pkg, specifier, { conditions, unsafe: true })?.[0]
if (target) return pathToFileURL(path.resolve(directory, target)).href
const legacyTarget =
specifier === pkg.name ? directory : path.resolve(directory, specifier.slice(pkg.name.length + 1))
return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(legacyTarget)).href
}
-3
View File
@@ -62,9 +62,6 @@ await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin-browser ===\n")
await $`bun ./packages/plugin-browser/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bun
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
const packages = ["@opencode-ai/simulation", "@opencode-ai/server"]
const publish = Bun.argv.includes("--publish")
if (!publish) {
console.log("Package names to reserve:")
for (const name of packages) console.log(`- ${name}`)
console.log("\nRun `bun run reserve-packages --publish` to log in and publish reservation placeholders.")
process.exit(0)
}
if (run(["npm", "whoami"]) !== 0) {
if (run(["npm", "login"]) !== 0) throw new Error("npm login failed")
if (run(["npm", "whoami"]) !== 0) throw new Error("npm authentication failed")
}
const directory = await mkdtemp(path.join(tmpdir(), "opencode-package-reservations-"))
try {
for (const name of packages) {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`)
if (response.ok) {
console.log(`Skipping ${name}: already exists on npm`)
continue
}
if (response.status !== 404) throw new Error(`Failed to check ${name}: npm returned ${response.status}`)
const target = path.join(directory, name.slice("@opencode-ai/".length))
await mkdir(target, { recursive: true })
await Bun.write(
path.join(target, "package.json"),
JSON.stringify(
{
name,
version: "0.0.0-reserved",
description: "Reserved for OpenCode",
license: "MIT",
repository: {
type: "git",
url: "git+https://github.com/anomalyco/opencode.git",
},
publishConfig: {
access: "public",
tag: "reserved",
},
},
null,
2,
) + "\n",
)
await Bun.write(path.join(target, "README.md"), `# ${name}\n\nReserved for OpenCode.\n`)
console.log(`Reserving ${name}`)
if (run(["npm", "publish", "--access", "public", "--tag", "reserved"], target) !== 0) {
throw new Error(`Failed to reserve ${name}`)
}
}
} finally {
await rm(directory, { recursive: true, force: true })
}
function run(command: string[], cwd?: string) {
return Bun.spawnSync(command, {
cwd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).exitCode
}