Compare commits

..
Author SHA1 Message Date
neriousy 2356e7bbad feat(server): gate external integration values by client API version
Clients now send x-opencode-api-version. Clients that send no header
(v2.0.24 and earlier) reject external integration methods, connections,
and credentials, so integration.list, integration.get, and credential.list
omit those values for them instead of failing the whole response.
2026-10-07 23:01:06 +00:00
50 changed files with 290 additions and 1419 deletions

No files matched your search

+2 -14
View File
@@ -131,7 +131,6 @@
"@opencode/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentunnel/client": "0.2.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
@@ -967,7 +966,6 @@
"name": "@opencode/ui",
"version": "2.0.24",
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-listener": "catalog:",
@@ -2020,8 +2018,6 @@
"@jsx-email/text": ["@jsx-email/text@1.0.2", "", { "peerDependencies": { "react": "^18.2.0" } }, "sha512-0zzwEwrKtY6tfjPJF0r3krKCDpP/ySYDvkn4+MvIFrIH5RZKmn3XDa5o/3hkbxMwpLn4MsXGIXn9XzMTaqTfUA=="],
"@kitlangton/solid-motion": ["@kitlangton/solid-motion@0.2.2", "", { "dependencies": { "motion-dom": "14.0.0", "motion-utils": "14.0.0" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-Fp7T8gJta3aRGJakBPQ6WIWmF6U6TmWhT6sGNNgkBiJ8cOVfuKkF9yYXzhvgRtKpo7WfzxtNLku907dLHhTDtw=="],
"@kobalte/core": ["@kobalte/core@0.13.13", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.2", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.2.0", "solid-prevent-scroll": "^0.1.11" }, "peerDependencies": { "solid-js": "^1.9.8" } }, "sha512-czBC+IQdOgJoW7DJjeh0rht7SPmTPgWsKg7Jo3RHMfwx028WxJtDylx9dJuJuD/GUae15+E4nYafu8LwuixFYQ=="],
"@kobalte/utils": ["@kobalte/utils@0.9.2", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-jRVXr+zsVHxzDXRoh+CDeXzvCsFJ6uiHhqqNQ26Cw9ZsZ3D6nqPUBt1gGVtj2ZPmRL3a9Uk1v8D1aJ8/I12Dow=="],
@@ -2328,10 +2324,6 @@
"@opentui/solid": ["@opentui/solid@0.5.16", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gCULWG+HORd5IVydiCchlmULgY1PQccODJvJKDNYJHDmhwyg9/sNSFUxoYajEXLZ40pUIL8ENAK+6p34Z9m/JQ=="],
"@opentunnel/client": ["@opentunnel/client@0.2.0", "", { "dependencies": { "@opentunnel/protocol": "0.2.0", "effect": "4.0.0-rc.112" } }, "sha512-UHbLK+y2aPdcTuu4FaCz3Ezcm52Z4TxGp5Hdy9aLYLsz9GUlPpPFaYXjdq8pFyfQlA9qAyI/z5zx5FlZx3aSbw=="],
"@opentunnel/protocol": ["@opentunnel/protocol@0.2.0", "", { "dependencies": { "effect": "4.0.0-rc.112" } }, "sha512-XQAm3InG70HysML+NiSPxCCfoP+ZzWuxXcCFFFh/qxrvMxuJyOrxsPT49vTo2rDCgrYYdbiFqpz8oXIJh0BYXw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
@@ -4798,9 +4790,9 @@
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
"motion-dom": ["motion-dom@14.0.0", "", { "dependencies": { "motion-utils": "14.0.0" } }, "sha512-aU3ApXo1yTpeUi3JU+jbDk2TELjxPE3MbBMUP7nZHlARb6RweJK+ojmLjpuB0YsY6zhbBl1YWGo8LMsI1/Llqw=="],
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion-utils": ["motion-utils@14.0.0", "", {}, "sha512-jBsyjVxTTRZNeuvd/v+btVXr90LQkYl4HhRu9T2ARc+PMnvkmjsjRVeAg4Qm10b6TlTw+kykWI8gu/6LuvpLSQ=="],
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -7470,10 +7462,6 @@
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"motion/framer-motion/motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"]
[test]
root = "./do-not-run-tests-from-root"
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-m8XFudp6AwHWDZO7rDXCTkNF0XzgdnWUAg0VCgmGoDQ=",
"aarch64-linux": "sha256-CQmS4coITqsMmtgi/g4Xivy/d6sDIklAE8duCUkAazs=",
"aarch64-darwin": "sha256-fSeacvDBLUCSKIeGCTgZsmqfo2rohARHvFgYLaNXHi8="
"x86_64-linux": "sha256-GHLheOh+zY847BpZ9G/vCfjPT7jUt8doODFOQP9REjw=",
"aarch64-linux": "sha256-+To0HYoZzNJ5Qd7y22/ZvljcxQbchGBvYaCPYhte2As=",
"aarch64-darwin": "sha256-KVGWrTpe8SB8PgKAyb+2FFRBvXLkJlJVsVTHa/bUhg0="
}
}
@@ -1012,99 +1012,6 @@ test.describe("background shortcut", () => {
await expect(backgroundCard).toContainText("Background task (background)")
})
for (const fixture of [
{ name: "from far below", before: 40, searches: 40, after: 80 },
// The collapsed timeline fits the viewport, so it only becomes scrollable once the group opens.
{ name: "that opens the last row", before: 0, searches: 40, after: 0 },
// Reaching the shell lands on the end of the timeline, which would otherwise follow new content again.
{ name: "at the end of the timeline", before: 40, searches: 0, after: 0 },
]) {
test(`holds a revealed shell ${fixture.name} just under its stuck group header`, async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: { timelineDetail: detailed },
sessionStatus: { [sessionID]: { type: "busy" } },
messages: [
userMessage(),
assistantMessage(
[
...(fixture.before ? [textPart("prt_earlier", notes("Earlier", fixture.before))] : []),
...searches(0, 42 - fixture.searches),
toolPart(
"prt_far_shell",
"shell",
"completed",
{ command: "sleep 120" },
{ output: "working", metadata: { shellID: "shell_far", status: "running" } },
),
// The open group outgrows the viewport, so aligning the group instead of the shell misses it.
...searches(42 - fixture.searches, fixture.searches),
...(fixture.after ? [textPart("prt_follow_up", notes("Follow-up", fixture.after))] : []),
],
{ completed: false },
),
],
})
await timeline.transport.send({
id: "evt_far_shell_created",
created: 3,
type: "shell.created",
location: { directory },
data: {
info: {
id: "shell_far",
status: "running",
command: "sleep 120",
cwd: directory,
shell: "bash",
file: "/tmp/far.out",
metadata: { sessionID },
time: { started: 2 },
},
},
})
const group = page
.locator('[data-component="collapsed-tool-group"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
const shellTrigger = page.locator('[data-timeline-part-id="prt_far_shell"] [data-slot="collapsible-trigger"]')
const aligned = () =>
expect
.poll(async () => {
const [header, shell] = await Promise.all([group.boundingBox(), shellTrigger.boundingBox()])
return header && shell ? Math.round(shell.y - (header.y + header.height)) : undefined
})
.toBe(0)
await expect(
fixture.after ? page.getByText(`Follow-up note ${fixture.after}.`, { exact: true }) : group,
).toBeInViewport()
await expect(shellTrigger).toHaveCount(0)
await page.getByRole("button", { name: "1 running", exact: true }).click()
await page
.getByRole("menu", { name: "1 running", exact: true })
.getByRole("menuitem", { name: /sleep 120/ })
.click()
await expect(group).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
await expect(shellTrigger).toBeInViewport()
// A shell at the very end cannot rise under the header until more content arrives below it.
if (fixture.searches) await aligned()
// Nothing covers the row, so it takes the next click.
await shellTrigger.click({ trial: true })
// The agent keeps writing below the shell; the shell stays where it was revealed.
await timeline.send(partUpdated(textPart("prt_late", notes("Late", 30))))
await expect(page.getByText("Late note 30.", { exact: true })).toBeAttached()
await aligned()
})
}
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
const childID = "ses_only_running_child"
@@ -1828,16 +1735,6 @@ function pauseExitAnimations(locator: Locator) {
})
}
function notes(label: string, count: number) {
return Array.from({ length: count }, (_, index) => `${label} note ${index + 1}.`).join("\n\n")
}
function searches(from: number, count: number) {
return Array.from({ length: count }, (_, index) =>
toolPart(`prt_search_${from + index}`, "grep", "completed", { pattern: `needle_${from + index}` }),
)
}
function runningSubagent(): SessionMessageAssistant {
return {
...completed,
@@ -110,14 +110,7 @@ export function createTimelineVirtualizer(input: Input) {
{ defer: true },
),
)
const [rendering, setRendering] = createStore<{
initialTail: boolean
scrollAdjustment: number
/** A revealed tool whose top edge stays put until the user scrolls. */
anchor?: { key: string; partID: string }
}>({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
@@ -144,7 +137,6 @@ export function createTimelineVirtualizer(input: Input) {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const initialTail = rendering.initialTail && input.pinned()
const anchored = rendering.anchor ? rowKeys().indexOf(rendering.anchor.key) : -1
return (range: Range) => {
// Batch a bounded cheap suffix, but stop before unknown/large content.
@@ -172,7 +164,7 @@ export function createTimelineVirtualizer(input: Input) {
: defaultRangeExtractor({ ...range, overscan: 2 })
return filterVirtualIndexes(
[...new Set([...indexes, ...[active, anchored].filter((index) => index >= 0)])].sort((a, b) => a - b),
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
@@ -344,7 +336,6 @@ export function createTimelineVirtualizer(input: Input) {
})
})
batchingColdSizes = false
pinAnchor()
if (coldPending) pinColdBottom()
settleColdBottom()
@@ -412,60 +403,19 @@ export function createTimelineVirtualizer(input: Input) {
const key = found.group.key
prepareNavigation()
// Opening the group and anchoring its row render the tool synchronously, wherever the row is.
batch(() => {
setToolOpen(
found.group.type === "context"
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
: { [key]: true },
)
setRendering("anchor", { key: TimelineRow.key(rows()[found.index]!), partID: found.partID })
})
// Until its ResizeObserver delivers, the opened row keeps its collapsed size, so the timeline may not scroll yet
// (and cannot unpin) or ends above the tool. Commit the real size first.
const opened = virtualContent?.querySelector<HTMLElement>(`[data-index="${found.index}"]`)
if (opened) resizeItem(found.index, opened.offsetHeight)
setToolOpen(
found.group.type === "context"
? { [`context:${key}`]: true, [`${key}:tool:${found.partID}`]: true }
: { [key]: true },
)
input.onUnpin()
pinAnchor()
prepareNavigation()
virtualizer.scrollToIndex(found.index, { align: "center" })
return true
}
// Puts the anchored tool's top edge at its scroll margin, just below the headers that stick above it. Its own
// growth extends downward, so only size changes at or above its row can move it; each of those calls this again.
function pinAnchor() {
const anchor = rendering.anchor
const root = listRoot()
if (!anchor || !root) return
const element = virtualContent?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`)
// Following the end is a different position to hold.
if (!element || !active() || input.pinned()) return releaseAnchor()
const offset = Math.min(
root.scrollHeight - root.clientHeight,
Math.max(
0,
root.scrollTop +
element.getBoundingClientRect().top -
root.getBoundingClientRect().top -
parseFloat(getComputedStyle(element).scrollMarginTop),
),
)
if (Math.abs(offset - root.scrollTop) > 1) virtualizer.scrollToOffset(offset)
}
function releaseAnchor() {
if (rendering.anchor) setRendering("anchor", undefined)
}
function prepareNavigation() {
releaseAnchor()
if (touchStart === undefined) touchScrolling = false
flushTouchAdjustment()
}
@@ -650,7 +600,6 @@ export function createTimelineVirtualizer(input: Input) {
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
releaseAnchor()
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
@@ -658,7 +607,6 @@ export function createTimelineVirtualizer(input: Input) {
const handleListTouchStart = (event: TouchEvent) => {
clearTouchTarget()
releaseAnchor()
input.onUserScroll(event.target)
touchScrolling = true
touchStart = event.touches[0]?.clientY
@@ -715,7 +663,6 @@ export function createTimelineVirtualizer(input: Input) {
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
releaseAnchor()
input.onUserScroll(event.target)
pointerHeld = true
}
@@ -741,7 +688,6 @@ export function createTimelineVirtualizer(input: Input) {
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
releaseAnchor()
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
@@ -760,8 +706,7 @@ export function createTimelineVirtualizer(input: Input) {
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
// An anchor holds its tool even when reaching it lands at the end; only the user's own scroll lets it go.
if (maxScroll <= 1 || (atEnd && arrived && !rendering.anchor)) input.onPin()
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
settleColdBottom()
input.onScheduleScrollState(root)
-1
View File
@@ -37,7 +37,6 @@
"@opencode/util": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentunnel/client": "0.2.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"diff": "catalog:",
+1 -33
View File
@@ -7,7 +7,6 @@ import { Script } from "@opencode/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@opencode/plugin/runtime-modules"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
@@ -83,30 +82,6 @@ export default () => readFileSync(archive)`,
}))
},
}
const pluginRuntimeEntries = discoverPluginRuntimeSpecifiers()
const pluginRuntimeModulesSource = [
"export const resolveHostPackageRoots = () => []",
"const modules = {",
...[...pluginRuntimeEntries.keys()].map(
(specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier, pluginRuntimeEntries)},`,
),
"}",
"export const loadRuntimeModules = () => modules",
].join("\n")
const pluginRuntimePlugin: BunPlugin = {
name: "opencode-plugin-runtime",
setup(build) {
build.onLoad({ filter: /plugin[/\\]src[/\\]runtime-modules\.ts$/ }, () => ({
contents: pluginRuntimeModulesSource,
loader: "ts",
}))
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
contents:
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
loader: "js",
}))
},
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
@@ -153,14 +128,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [
appAssetsPlugin,
solidPlugin,
parcelWatcherPlugin,
opencodePtyPlugin,
pluginRuntimePlugin,
simulationGraphPlugin,
],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
-4
View File
@@ -116,10 +116,6 @@ function copyBinary(source) {
function resolveBinary(name) {
const packagePath = require.resolve(`${name}/package.json`)
// Package managers keep an older platform package when the matching version is not yet on the registry,
// which would silently install the previous release under the new launcher.
const version = JSON.parse(fs.readFileSync(packagePath, "utf8")).version
if (version !== dependencies[name]) throw new Error(`${name} is ${version}, expected ${dependencies[name]}`)
return path.join(path.dirname(packagePath), "bin", sourceBinary)
}
-4
View File
@@ -530,10 +530,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
Flag.optional,
),
remote: Flag.boolean("remote").pipe(
Flag.withDescription("Pair through the OpenTunnel remote address, enabling remote access if needed"),
Flag.withDefault(false),
),
},
}),
Spec.make("serve", {
+6 -38
View File
@@ -1,28 +1,24 @@
import { EOL } from "os"
import { Effect, Option, Schedule } from "effect"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { RemoteTunnel } from "../../services/remote-tunnel"
import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
const config = yield* ServiceConfig.read()
if (config.disabled === true)
if ((yield* ServiceConfig.read()).disabled === true)
return yield* Effect.fail(
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
)
if (input.remote && Option.isSome(input.url))
return yield* Effect.fail(new Error("--remote cannot be combined with --url"))
// Changing the setting restarts the service, and the ensure below starts it again with the tunnel.
if (input.remote && config.remote !== true) yield* ServiceConfig.set("remote", "true")
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = yield* pairingURLs(client, input)
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() => client.server.info())).urls
const pairing = yield* Effect.tryPromise(() => client.server.pair())
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
// Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses.
@@ -49,7 +45,7 @@ export default Runtime.handler(
].join(EOL) + EOL,
)
if (input.remote || Option.isSome(input.url)) return
if (Option.isSome(input.url)) return
const url = new URL(endpoint.url)
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
process.stderr.write(
@@ -65,34 +61,6 @@ export default Runtime.handler(
}),
)
const pairingURLs = Effect.fnUntraced(function* (
client: ReturnType<typeof OpenCode.make>,
input: Runtime.Input<typeof Commands.commands.pair>,
) {
if (input.remote) return [yield* remoteURL(client)]
if (Option.isSome(input.url)) return [input.url.value]
return (yield* Effect.tryPromise(() => client.server.info())).urls
})
// The service attaches the tunnel in the background, so wait for its URL to appear in server info.
const remoteURL = Effect.fnUntraced(function* (client: ReturnType<typeof OpenCode.make>) {
const tunnelURL = Effect.gen(function* () {
const hostname = yield* RemoteTunnel.hostname()
const info = yield* Effect.tryPromise(() => client.server.info())
const url = info.urls.find((candidate) => hostname !== undefined && new URL(candidate).hostname === hostname)
if (url === undefined) return yield* Effect.fail(new Error("Remote tunnel is not ready"))
return url
})
return yield* tunnelURL.pipe(
Effect.retry({ schedule: Schedule.spaced("1 second") }),
Effect.timeoutOrElse({
duration: "3 minutes",
orElse: () =>
Effect.fail(new Error("Timed out waiting for the remote tunnel; run `opencode pair --remote` again to retry")),
}),
)
})
function isLoopback(hostname: string) {
return (
hostname === "localhost" || hostname.endsWith(".localhost") || hostname.startsWith("127.") || hostname === "[::1]"
-3
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import { ensurePluginRuntime } from "@opencode/plugin/runtime"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
@@ -18,8 +17,6 @@ import { EffectFlock } from "@opencode/util/effect-flock"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
ensurePluginRuntime()
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
const { askpass } = await import("./ssh-askpass")
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
-16
View File
@@ -14,7 +14,6 @@ import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { RetainedImage } from "./services/retained-image"
import { ServiceRegistration } from "./services/service-registration"
import { RemoteTunnel } from "./services/remote-tunnel"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -89,7 +88,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const remote = { urls: [] as ReadonlyArray<string> }
const launch = start(
{
app: {
@@ -146,7 +144,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
},
transform,
() => remote.urls,
)
const server = yield* launch.pipe(
Effect.catch((error) => {
@@ -173,19 +170,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
)
if (server === undefined) return
if (serviceOptions !== undefined && config.remote === true && server.address._tag === "TcpAddress") {
const bound = server.address.hostname
// A wildcard bind also listens on loopback, which is all the tunnel needs to reach.
const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound
yield* Effect.forkScoped(
RemoteTunnel.run({
target: `${host}:${server.address.port}`,
onURL: (url) => {
remote.urls = url === undefined ? [] : [url]
},
}),
)
}
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
@@ -1,73 +0,0 @@
export * as RemoteTunnel from "./remote-tunnel"
import type { OpenTunnelError } from "@opentunnel/client/effect"
import { Cause, Effect, Schedule } from "effect"
import { EOL } from "os"
import { OPENCODE_CHANNEL } from "../version"
// OpenTunnel keeps one tunnel per device in its default profile, shared by the opentunnel CLI and every app
// using the SDK; each claims its own routes. The service claims a subdomain rather than the tunnel hostname,
// which the CLI may route, and each channel's service gets its own subdomain.
export function route(channel = OPENCODE_CHANNEL) {
if (channel === "latest") return "opencode"
return `opencode-${channel.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`.slice(0, 63).replace(/-+$/, "")
}
// Holds the route for the life of the service. The SDK reconnects through network failures itself, so only
// setup failures reach the retry here; a rejected token or failed certificate stops it for good.
export const run = Effect.fnUntraced(function* (input: {
readonly target: string
readonly onURL: (url: string | undefined) => void
}) {
const { OpenTunnelClient, OpenTunnelAttachError } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
const fatal = (error: OpenTunnelError) =>
error._tag === "OpenTunnelClientError" &&
(error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed"))
yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
const connection = yield* client.tunnel.connect({ routes: { [route()]: input.target } })
input.onURL(`https://${route()}.${connection.tunnel.hostname}`)
yield* connection.closed
}).pipe(
Effect.scoped,
Effect.ensuring(Effect.sync(() => input.onURL(undefined))),
Effect.tapError((error) =>
fatal(error) ? Effect.void : Effect.logWarning("remote access tunnel unavailable; retrying", { cause: error }),
),
Effect.retry({
while: (error) => !fatal(error),
schedule: Schedule.min([Schedule.exponential("1 second"), Schedule.spaced("30 seconds")]),
}),
Effect.provide(OpenTunnelClient.layer()),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logError("remote access tunnel stopped; run `opencode service set remote true` to retry", { cause }),
),
)
})
// A device without a tunnel creates its shared one here, in the foreground, because issuing the certificate
// takes a while; the service then only attaches its route on start. An interrupted issuance resumes next time.
export const ensure = Effect.fnUntraced(function* () {
const { OpenTunnelClient } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
return yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
if ((yield* client.tunnel.get()) === undefined)
process.stderr.write("Setting up remote access; this can take a minute..." + EOL)
return `${route()}.${(yield* client.tunnel.ensure()).hostname}`
}).pipe(
Effect.provide(OpenTunnelClient.layer()),
Effect.timeoutOrElse({
duration: "5 minutes",
orElse: () => Effect.fail(new Error("Timed out creating the remote access tunnel; run the command again to resume")),
}),
)
})
// The tunnel hostname is persisted once the certificate is ready, so this is undefined until then.
export const hostname = Effect.fnUntraced(function* () {
const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
const identity = yield* OpenTunnelStorage.xdg().load("default")
return identity === undefined ? undefined : `${route()}.${identity.hostname}`
})
+1 -21
View File
@@ -6,7 +6,6 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import { selfCommand } from "../util/process"
import { RemoteTunnel } from "./remote-tunnel"
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
// points the client package's service operations at this CLI: which
@@ -14,7 +13,6 @@ import { RemoteTunnel } from "./remote-tunnel"
export const Info = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
remote: Schema.optional(Schema.Boolean),
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
@@ -23,7 +21,7 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type
const keys = ["disabled", "remote", "hostname", "port", "password", "cors", "env"] as const
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
@@ -83,7 +81,6 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
function configKey(key: string): Key {
if (
key === "disabled" ||
key === "remote" ||
key === "hostname" ||
key === "port" ||
key === "password" ||
@@ -166,9 +163,6 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
case "disabled": {
return String((yield* read()).disabled ?? false)
}
case "remote": {
return String((yield* read()).remote ?? false)
}
case "hostname": {
return (yield* read()).hostname ?? ""
}
@@ -201,14 +195,6 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
yield* write({ ...(yield* read()), disabled: value === "true" })
return
}
case "remote": {
if (value !== "true" && value !== "false") throw new Error("Remote must be true or false")
// A tunnel that cannot be created leaves remote access off instead of a service that keeps retrying.
if (value === "true") yield* RemoteTunnel.ensure()
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), remote: value === "true" })
return
}
case "hostname": {
yield* Service.stop(yield* options())
yield* write({ ...(yield* read()), hostname: value })
@@ -258,12 +244,6 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
yield* write(next)
return
}
case "remote": {
yield* Service.stop(yield* options())
const { remote: _remote, ...next } = yield* read()
yield* write(next)
return
}
case "hostname": {
yield* Service.stop(yield* options())
const { hostname: _hostname, ...next } = yield* read()
-13
View File
@@ -1,13 +0,0 @@
import { validateRoutes } from "@opentunnel/client/effect"
import { expect, test } from "bun:test"
import { RemoteTunnel } from "../src/services/remote-tunnel"
test("each channel serves remote access on its own valid subdomain of the shared tunnel", () => {
const channels = ["latest", "dev", "beta", "Preview/Feature_X.1", "a".repeat(80), "trailing-"]
const routes = channels.map((channel) => RemoteTunnel.route(channel))
expect(routes.slice(0, 3)).toEqual(["opencode", "opencode-dev", "opencode-beta"])
expect(new Set(routes).size).toBe(routes.length)
// The SDK rejects invalid route names, which would keep the service from ever attaching.
expect(() => validateRoutes(Object.fromEntries(routes.map((name) => [name, "127.0.0.1:4096"])))).not.toThrow()
})
-20
View File
@@ -43,26 +43,6 @@ test("service disabled accepts only booleans without changing configuration on i
}
})
// Enabling remote creates a real tunnel, so only the paths that stay local are covered here.
test("service remote accepts only booleans and persists across set and unset", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-config-"))
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
try {
expect(await run(ServiceConfig.get("remote"))).toBe("false")
await expect(run(ServiceConfig.set("remote", "on"))).rejects.toThrow("Remote must be true or false")
expect(await run(ServiceConfig.read())).toEqual({})
await run(ServiceConfig.set("remote", "false"))
expect(await run(ServiceConfig.read())).toEqual({ remote: false })
expect(await run(ServiceConfig.get("remote"))).toBe("false")
await run(ServiceConfig.unset("remote"))
expect(await run(ServiceConfig.read())).toEqual({})
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
+3
View File
@@ -0,0 +1,3 @@
// Mirrors @opencode/protocol/api-version, which the dependency-free promise client cannot import.
export const API_VERSION_HEADER = "x-opencode-api-version"
export const API_VERSION = 2
+5 -3
View File
@@ -1,5 +1,6 @@
export * as OpenCode from "./client.js"
import { API_VERSION, API_VERSION_HEADER } from "../api-version.js"
import { Cause, Context, Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SharedEvents } from "../shared-events.js"
@@ -17,9 +18,10 @@ export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly b
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequestEffect(httpClient, (request) =>
Effect.map(CurrentHeaders, (headers) =>
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
),
Effect.map(CurrentHeaders, (headers) => {
const versioned = HttpClientRequest.setHeader(request, API_VERSION_HEADER, String(API_VERSION))
return headers ? HttpClientRequest.setHeaders(versioned, new Headers(headers)) : versioned
}),
),
),
)
+4 -1
View File
@@ -1,5 +1,6 @@
export * as OpenCode from "./client.js"
import { API_VERSION, API_VERSION_HEADER } from "../api-version.js"
import { SharedEvents } from "../shared-events.js"
import { OpenCode } from "./generated/index.js"
import type { ClientOptions } from "./generated/client.js"
@@ -8,7 +9,9 @@ import { makeRpc } from "./rpc.js"
export type { ClientOptions, RequestOptions } from "./generated/client.js"
export function make(options: ClientOptions) {
const raw = OpenCode.make(options)
const headers = new Headers(options.headers)
headers.set(API_VERSION_HEADER, String(API_VERSION))
const raw = OpenCode.make({ ...options, headers })
const events = SharedEvents.make((signal, onActivity) => raw.event.subscribe({ signal, onActivity }))
return {
...raw,
@@ -37,3 +37,9 @@ test("shared DTO schemas construct and decode plain objects", () => {
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
})
test("client API version mirrors the protocol", async () => {
const client = await import("../src/api-version")
const protocol = await import("@opencode/protocol/api-version")
expect(client).toEqual(protocol)
})
+20
View File
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { Context, DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { API_VERSION, API_VERSION_HEADER } from "../src/api-version"
import {
AbsolutePath,
Agent,
@@ -42,6 +43,25 @@ test("server.info decodes the readiness response", async () => {
})
})
test("requests send the client's API version", async () => {
const headers: Array<string | undefined> = []
const httpClient = HttpClient.make((request) => {
headers.push(request.headers[API_VERSION_HEADER])
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ version: "current", pid: 123, urls: [], paths: { tmp: "/tmp/opencode" } }),
),
)
})
await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
yield* client.server.info()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(headers).toEqual([String(API_VERSION)])
})
test("vcs.base decodes nullable review-base metadata", async () => {
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
const base = {
+17
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { API_VERSION, API_VERSION_HEADER } from "../src/api-version"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src/promise/index"
test("exposes every standard HTTP API group", () => {
@@ -1125,3 +1126,19 @@ const modelSwitchedEvent = {
model: { id: "claude", providerID: "anthropic" },
},
}
test("requests send the client's API version alongside configured headers", async () => {
let headers: Headers | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Basic secret", [API_VERSION_HEADER]: "1" },
fetch: async (input, init) => {
headers = new Headers(init?.headers)
return Response.json({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp" } })
},
})
await client.server.info()
expect(headers?.get(API_VERSION_HEADER)).toBe(String(API_VERSION))
expect(headers?.get("authorization")).toBe("Basic secret")
})
+6 -13
View File
@@ -8,7 +8,6 @@ export * as WriteTool from "./write.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import { ToolFailure } from "@opencode/ai"
import { FileDiff } from "@opencode/schema/file-diff"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode/util/bom"
import { Environment } from "../../environment/index.js"
@@ -33,7 +32,6 @@ export const Output = Schema.Struct({
target: Schema.String,
resource: Schema.String,
existed: Schema.Boolean,
files: Schema.Array(FileDiff.Info),
})
export type Output = typeof Output.Type
@@ -87,18 +85,13 @@ export const Plugin = {
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const written = yield* FileMutation.readText(environment.files, target.absolute)
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
: written.text
return {
...result,
files: [
fileDiff(result.resource, current?.text ?? "", formatted, result.existed ? "modified" : "added"),
],
} satisfies Output
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelContent(output), metadata: { files: output.files } })),
Effect.map((output) => ({ output, content: toModelContent(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}),
+4 -306
View File
@@ -1,32 +1,21 @@
import { expect } from "bun:test"
import { cp } from "node:fs/promises"
import path from "node:path"
import { Brand, Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
import { Agent } from "@opencode/schema/agent"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Plugin } from "@opencode/core/plugin"
import { Config } from "@opencode/core/config"
import { ManagedPolicy } from "@opencode/core/managed-policy"
import { PluginModule } from "@opencode/core/plugin/module"
import { Rpc } from "@opencode/core/rpc"
import { Tool } from "@opencode/core/tool"
import { execute } from "@opencode/core/tool/runtime"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { createForeignPackageFilter, ensurePluginRuntime } from "@opencode/plugin/runtime"
import { createLoader, discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@opencode/plugin/runtime-modules"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
ensurePluginRuntime()
const it = testEffect(
Layer.mergeAll(
PluginTestLayer,
Config.testLayer(),
ManagedPolicy.layer,
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
),
@@ -106,297 +95,6 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
}),
)
it.live("loads plugins and their transitive dependencies against the host's Effect and @opencode/plugin instances", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "plugin")
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
yield* Effect.promise(async () => {
await cp(path.join(hostEffectDir, "dist"), path.join(pluginEffectDir, "dist"), {
recursive: true,
filter: (src) => !src.endsWith(".d.ts") && !src.endsWith(".map") && !/httpApi(?:Scalar|Swagger)\.js$/.test(src),
})
const pkg = { ...(await Bun.file(path.join(hostEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
// Sabotage the plugin's own Effect copy with the version-skew failure modes so loading it would crash:
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
const originalInternalEffect = await Bun.file(internalEffectPath).text()
const logPattern =
/const logLevel = level \?\? fiber\.(?:currentLogLevel|cache\.logLevel);\r?\n\s*if \(isLogLevelGreaterThan\(fiber\.(?:minimumLogLevel|cache\.minimumLogLevel), logLevel\)\) \{/
const runPromisePattern = /const runPromiseExit = runPromiseExitWith\(context\);/
expect(logPattern.test(originalInternalEffect)).toBe(true)
expect(runPromisePattern.test(originalInternalEffect)).toBe(true)
await writeFiles(pluginDir, {
"node_modules/effect/package.json": JSON.stringify(pkg),
"node_modules/effect/dist/internal/effect.js": originalInternalEffect
.replace(
logPattern,
"const logLevel = level ?? fiber.foreignSkew?.logLevel;\n if (isLogLevelGreaterThan(fiber.foreignSkew?.minimumLogLevel, logLevel)) {",
)
.replace(
runPromisePattern,
"if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);",
),
"node_modules/transitive-dep/package.json":
'{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}',
"node_modules/transitive-dep/index.js": `import { Effect, Schema } from "effect"
import { some } from "effect/Option"
export const depToolInput = Schema.Struct({
mode: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("from-dep"))),
count: Schema.Int,
code: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
})
export const depCaptured = { Effect, Schema, some }`,
"node_modules/@opencode/plugin/package.json":
'{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}',
"node_modules/@opencode/plugin/effect.js": "export const Plugin = { define: (p) => p }",
"node_modules/@opencode/plugin/rpc.js": "export const Rpc = { define: (d) => d }",
"index.ts": `import { Plugin } from "@opencode/plugin/effect"
import { Rpc } from "@opencode/plugin/rpc"
import { Effect, Schema } from "effect"
import { some } from "effect/Option"
import { nominal } from "effect/Brand"
import { depCaptured, depToolInput } from "transitive-dep"
export const captured = {
plugin: { Effect, Schema, some, nominal },
dep: depCaptured,
pluginCount: -1,
}
const Contract = Rpc.define({
id: "host-effect-rpc",
methods: {
check: {
input: Schema.Struct({
count: Schema.Int.pipe(Schema.withDecodingDefault(Effect.succeed(5))),
tag: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
}),
output: Schema.Struct({ value: Schema.String }),
},
},
events: {},
})
export default Plugin.define({
id: "host-effect-fixture",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.log("setup log from plugin")
const listed = yield* Effect.promise(() =>
Effect.runPromise(ctx.plugin.list().pipe(Effect.orDie)),
)
captured.pluginCount = listed.data.length
yield* ctx.tool.transform((editor) => {
editor.add({
name: "check_tool",
description: "Tool with decoding default from transitive dependency and Int/Trim/isPattern checks",
input: depToolInput,
output: Schema.Struct({ formatted: Schema.String }),
execute: ({ mode, count, code }) =>
Effect.log("executing check_tool").pipe(
Effect.as({
output: { formatted: \`\${mode}:\${code}:\${count}\` },
content: \`\${mode}:\${code}:\${count}\`,
}),
),
})
})
yield* ctx.rpc.register(Contract, {
check: ({ count, tag }) =>
Effect.log("executing rpc check").pipe(
Effect.as({ value: \`\${tag}#\${count}\` }),
),
}).pipe(Effect.orDie)
}),
})`,
})
})
const modules = yield* PluginModule.make()
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const rpc = yield* Rpc.Service
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
if ("pending" in definition || "blocked" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
yield* plugins.activate([definition])
yield* plugins.awaitActivation
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
expect(imported.captured.plugin.Effect).toBe(Effect)
expect(imported.captured.plugin.Schema).toBe(Schema)
expect(imported.captured.plugin.some).toBe(Option.some)
expect(imported.captured.plugin.nominal).toBe(Brand.nominal)
expect(imported.captured.dep.Effect).toBe(Effect)
expect(imported.captured.dep.Schema).toBe(Schema)
expect(imported.captured.dep.some).toBe(Option.some)
expect(imported.captured.pluginCount).toBe(0)
const checkTool = (yield* tools.list()).find((tool) => tool.id === "check_tool")
expect(checkTool).toBeDefined()
if (!checkTool) return
const context = {
sessionID: Session.ID.make("ses_host_effect"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_host_effect"),
id: Tool.CallID.make("call_host_effect"),
progress: () => Effect.void,
}
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
output: { formatted: "from-dep:v42:3" },
content: [{ type: "text", text: "from-dep:v42:3" }],
})
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
}),
)
it.live("redirects plugin dependencies with a nested Effect 3 installation to the host, allows effect/package.json, and fails loudly on unprovided subpaths", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const pluginDir = path.join(directory.path, "v3-dep-plugin")
const badDir = path.join(directory.path, "removed-subpath-plugin")
const v3DepDir = path.join(pluginDir, "node_modules/v3-dep")
yield* Effect.promise(() =>
writeFiles(directory.path, {
"v3-dep-plugin/node_modules/effect/package.json":
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}',
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/package.json":
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}',
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/index.js":
"export const Effect = { major: 3 }; export const Schema = { major: 3 }",
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/Option.js": "export const some = () => ({ major: 3 })",
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/ReadonlyArray.js": "export const fromIterable = () => []",
"v3-dep-plugin/node_modules/v3-dep/package.json":
'{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}',
"v3-dep-plugin/node_modules/v3-dep/index.js":
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const v3DepCaptured = { Effect, Schema, some }',
"v3-dep-plugin/node_modules/v3-dep/v3-only.js":
'import { fromIterable } from "effect/ReadonlyArray"; export { fromIterable }',
"v3-dep-plugin/index.ts":
'import pkg from "effect/package.json" with { type: "json" }; import { v3DepCaptured } from "v3-dep"; export const effectPkgName = pkg.name; export { v3DepCaptured }; export default { id: "v3-dep-plugin", async setup() {} }',
"removed-subpath-plugin/node_modules/effect/package.json":
'{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}',
"removed-subpath-plugin/node_modules/effect/RemovedLegacySubpath.js": "export const legacy = true",
"removed-subpath-plugin/index.ts":
'import { legacy } from "effect/RemovedLegacySubpath"; export default { id: "removed-subpath", async setup() { void legacy } }',
}),
)
const modules = yield* PluginModule.make()
expect(yield* modules.load({ type: "add", target: pluginDir, options: {} })).toMatchObject({ id: "v3-dep-plugin" })
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
expect(imported.effectPkgName).toBe("effect")
expect(imported.v3DepCaptured.Effect).toBe(Effect)
expect(imported.v3DepCaptured.Schema).toBe(Schema)
expect(imported.v3DepCaptured.some).toBe(Option.some)
yield* Effect.promise(async () => {
await expect(import(path.join(v3DepDir, "v3-only.js"))).rejects.toThrow("effect/ReadonlyArray.js")
})
const exit = yield* modules.load({ type: "add", target: badDir, options: {} }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(String(Cause.squash(exit.cause))).toContain("effect/RemovedLegacySubpath.js")
}
}),
)
it.live("discovers exported specifiers from the resolved tree even when dist/ exists, and validates barrel loader routing, async loader dedup, and Windows foreign filters", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const consumerDir = path.join(directory.path, "consumer")
const jitFixtureDir = path.join(directory.path, "jit-fixture")
const asyncModPath = path.join(directory.path, "async-mod.ts")
yield* Effect.promise(() =>
writeFiles(directory.path, {
"consumer/node_modules/@opencode/plugin/package.json": JSON.stringify({
name: "@opencode/plugin",
type: "module",
exports: {
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./effect/*": "./src/effect/*.ts",
},
}),
"consumer/node_modules/@opencode/plugin/src/promise/index.ts": "export const root = 'src'",
"consumer/node_modules/@opencode/plugin/src/effect/index.ts": "export * as plugin from './plugin.ts'",
"consumer/node_modules/@opencode/plugin/src/effect/plugin.ts": "export const leaf = 'src'",
"consumer/node_modules/@opencode/plugin/src/unexported.ts": "export const secret = true",
"consumer/node_modules/@opencode/plugin/dist/promise/index.js": "export const root = 'dist'",
"consumer/node_modules/@opencode/plugin/dist/effect/index.js": "export const index = 'dist'",
"consumer/node_modules/@opencode/plugin/dist/effect/plugin.js": "export const leaf = 'dist'",
"jit-fixture/SchemaJITCompiler.js": "export const enable = () => 'fn'",
"jit-fixture/enable.js": "export {}",
"async-mod.ts": "await new Promise((r) => setTimeout(r, 20)); export const nonce = Math.random()",
}),
)
const discovered = discoverPluginRuntimeSpecifiers(consumerDir, ["@opencode/plugin"])
expect(discovered.get("@opencode/plugin")?.replaceAll("\\", "/")).toEndWith("src/promise/index.ts")
expect(discovered.get("@opencode/plugin/effect")?.replaceAll("\\", "/")).toEndWith("src/effect/index.ts")
expect(discovered.get("@opencode/plugin/effect/plugin")?.replaceAll("\\", "/")).toEndWith("src/effect/plugin.ts")
expect(discovered.has("@opencode/plugin/unexported")).toBe(false)
const hostDiscovered = new Map(discoverPluginRuntimeSpecifiers())
hostDiscovered.set("effect/schema/SchemaJITCompiler", path.join(jitFixtureDir, "SchemaJITCompiler.js"))
hostDiscovered.set("effect/schema/SchemaJITCompiler/enable", path.join(jitFixtureDir, "enable.js"))
expect(pluginRuntimeLoaderCode("effect/Option", hostDiscovered)).toBe('() => require("effect")["Option"]')
expect(pluginRuntimeLoaderCode("effect/testing", hostDiscovered)).toBe('() => require("effect/testing")')
expect(pluginRuntimeLoaderCode("effect/unstable/http/MultipartParser/HeadersParser", hostDiscovered)).toBe(
'() => require("effect/unstable/http/MultipartParser/HeadersParser")',
)
expect(pluginRuntimeLoaderCode("effect/schema/SchemaJITCompiler/enable", hostDiscovered)).toBe(
'() => require("effect/schema/SchemaJITCompiler/enable")',
)
const loadAsync = createLoader(asyncModPath)
const first = loadAsync()
const second = loadAsync()
expect(first).toBeInstanceOf(Promise)
expect(first).toBe(second)
const [res1, res2] = yield* Effect.promise(() => Promise.all([first, second]))
expect(res1).toBe(res2)
const winFilter = createForeignPackageFilter([
"C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect",
])
expect(
winFilter.test(
"C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect\\dist\\index.js",
),
).toBe(false)
expect(
winFilter.test(
"C:/runner/_work/opencode/node_modules/.bun/effect@4.0.0-rc.112/node_modules/effect/dist/index.js",
),
).toBe(false)
expect(winFilter.test("C:\\Users\\plugin\\node_modules\\effect\\dist\\index.js")).toBe(true)
expect(winFilter.test("C:/Users/plugin/node_modules/effect/dist/index.js")).toBe(true)
expect(winFilter.test("C:\\Users\\user\\.bun\\install\\cache\\effect@4.0.1@@@1\\dist\\index.js")).toBe(true)
expect(winFilter.test("C:/Users/user/.bun/install/cache/effect@4.0.1@@@1/dist/index.js")).toBe(true)
}),
)
async function writeFiles(root: string, files: Record<string, string>) {
await Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(root, file), text)))
}
it.live("blocks a local plugin before module initialization and honors replacement policies", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
+8 -39
View File
@@ -110,15 +110,6 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write", "execute"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
const files = [
{
file: "src/new.txt",
status: "added",
additions: 1,
deletions: 0,
patch: expect.stringContaining("+created"),
},
]
expect(settled).toEqual({
status: "completed",
output: {
@@ -126,10 +117,8 @@ describe("WriteTool", () => {
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
resource: "src/new.txt",
existed: false,
files,
},
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
metadata: { files },
})
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
"created",
@@ -156,7 +145,7 @@ describe("WriteTool", () => {
}),
)
it.live("formats the committed file and returns the diff for final formatted content", () =>
it.live("formats the committed file", () =>
withTempDir((tmp) => {
const fixture = makeWriteFixture()
const target = path.join(tmp.path, "formatted.txt")
@@ -165,33 +154,13 @@ describe("WriteTool", () => {
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
return true
})
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
Effect.andThen(
withTool(tmp.path, fixture, (registry) =>
executeTool(registry, call({ path: "formatted.txt", content: "format me\n" })),
),
),
Effect.andThen((settled) =>
Effect.gen(function* () {
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
const files = [
{
file: "formatted.txt",
status: "modified",
additions: 1,
deletions: 1,
patch: expect.stringMatching(/-before\n\+FORMAT ME\n/),
},
]
expect(settled.output).toMatchObject({ files })
expect(settled.metadata).toMatchObject({ files })
expect(fixture.assertions[0]?.metadata).toMatchObject({
files: [{ patch: expect.stringContaining("+format me") }],
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME\n")
}),
),
return withTool(tmp.path, fixture, (registry) =>
Effect.gen(function* () {
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
status: "completed",
})
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
}),
)
}),
)
-18
View File
@@ -1,18 +0,0 @@
# `@opencode/plugin`
Authoring interfaces and runtime loader support for OpenCode V2 plugins:
- `@opencode/plugin` — [Promise plugin API](./src/README.md)
- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md)
- `@opencode/plugin/rpc` — portable RPC contract definitions
- `@opencode/plugin/tui` — terminal UI plugin API
## Packaging And Runtime `effect`
When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy.
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency.
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals.
- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported.
- Only public `effect` subpaths are provided. A plugin that imports one of Effect's private `internal` modules fails to load with an error naming the path.
- The compiled OpenCode binary does not include the Scalar and Swagger UI assets used by Effect's HTTP API docs pages; serving those pages from a plugin shows a notice instead.
-1
View File
@@ -13,7 +13,6 @@
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./host": "./src/host.ts",
"./runtime": "./src/runtime.ts",
"./tui": "./src/tui/index.ts",
"./*": "./src/*.ts"
},
-113
View File
@@ -1,113 +0,0 @@
import { existsSync, realpathSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
export type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
const runtimePackages = ["effect", "@opencode/plugin"] as const
export function resolveHostPackageRoots(from = import.meta.dir, packages: readonly string[] = runtimePackages) {
return packages.flatMap((pkgName) => {
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
return [dir, findNodeModulesDir(pkgName, from, dir)]
})
}
export function loadRuntimeModules(): Readonly<Record<string, RuntimeModuleLoader>> {
const entries = discoverPluginRuntimeSpecifiers()
const effectEntry = entries.get("effect")
if (effectEntry) require(effectEntry)
return Object.fromEntries(
[...entries.entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
)
}
export function discoverPluginRuntimeSpecifiers(
from = import.meta.dir,
packages: readonly string[] = runtimePackages,
): ReadonlyMap<string, string> {
const entries = new Map<string, string>()
for (const pkgName of packages) {
const realDir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
// Resolve workspace @opencode/plugin paths via node_modules so OpenTUI does not wrap host files in async rewrite loaders.
const loadDir = findNodeModulesDir(pkgName, from, realDir)
const toLoadPath = (resolved: string) =>
loadDir === realDir ? resolved : path.join(loadDir, path.relative(realDir, resolved))
const rootEntry = Bun.resolveSync(pkgName, from)
const relParts = path.relative(realDir, rootEntry).replaceAll("\\", "/").split("/")
const scanDir = relParts.length > 1 ? path.join(realDir, relParts[0]) : realDir
const ext = path.extname(rootEntry) || ".js"
entries.set(pkgName, toLoadPath(rootEntry))
for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) {
const normalized = file.replaceAll("\\", "/")
if (
normalized.startsWith("internal/") ||
normalized.includes("/internal/") ||
normalized.startsWith("source.") ||
normalized.startsWith("runtime")
) {
continue
}
const base = normalized.slice(0, -ext.length)
if (base === "index") continue
const candidates = base.endsWith("/index")
? [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`]
: [`${pkgName}/${base}`]
for (const specifier of candidates) {
if (entries.has(specifier)) continue
try {
entries.set(specifier, toLoadPath(Bun.resolveSync(specifier, from)))
} catch {}
}
}
}
return entries
}
export function pluginRuntimeLoaderCode(specifier: string, entries: ReadonlyMap<string, string>) {
if (specifier.startsWith("effect/")) {
const slash = specifier.lastIndexOf("/")
const parent = specifier.slice(0, slash)
const member = specifier.slice(slash + 1)
const parentResolved = entries.get(parent)
const resolved = entries.get(specifier)
if (
member !== "index" &&
parentResolved &&
resolved &&
(require(parentResolved) as Record<string, unknown>)[member] === require(resolved)
) {
return `() => require(${JSON.stringify(parent)})[${JSON.stringify(member)}]`
}
}
return `() => require(${JSON.stringify(specifier)})`
}
export function createLoader(resolved: string): RuntimeModuleLoader {
let cached: Record<string, unknown> | undefined
let pending: Promise<Record<string, unknown>> | undefined
return () => {
if (cached) return cached
if (pending) return pending
try {
return (cached = require(resolved) as Record<string, unknown>)
} catch {
return (pending = import(pathToFileURL(resolved).href).then(
(mod: Record<string, unknown>) => (cached = mod),
(error) => {
pending = undefined
throw error
},
))
}
}
}
function findNodeModulesDir(pkgName: string, from: string, realDir: string) {
if (/[/\\]node_modules[/\\]/.test(realDir)) return realDir
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
const candidate = path.join(dir, "node_modules", pkgName)
if (existsSync(candidate) && realpathSync(candidate) === realDir) return candidate
if (path.dirname(dir) === dir) return realDir
}
}
-52
View File
@@ -1,52 +0,0 @@
import { loadRuntimeModules, resolveHostPackageRoots, type RuntimeModuleLoader } from "./runtime-modules.js"
const foreignPkgSuffix = String.raw`(?:node_modules[/\\](@opencode[/\\]plugin|effect)|(@opencode[/\\]plugin|effect)@[^/\\]+@@@\d+)`
const foreignPkgPattern = new RegExp(String.raw`^(.*[/\\]${foreignPkgSuffix})[/\\](.+)$`)
let installed: Readonly<Record<string, RuntimeModuleLoader>> | undefined
export function provides(specifier: string) {
return installed !== undefined && Object.hasOwn(installed, specifier)
}
export function ensurePluginRuntime() {
if (typeof Bun === "undefined") return {}
if (installed) return installed
const modules = loadRuntimeModules()
installed = modules
Bun.plugin({
name: "opencode-plugin-runtime",
setup(build) {
for (const [specifier, load] of Object.entries(modules)) {
build.module(specifier, () => {
const exports = load()
return exports instanceof Promise
? exports.then((value) => ({ exports: value, loader: "object" as const }))
: { exports, loader: "object" as const }
})
}
build.onLoad({ filter: createForeignPackageFilter() }, (args) => {
const match = args.path.match(foreignPkgPattern)
const target = match ? `${match[2] ?? match[3]}/${match[4]}`.replaceAll("\\", "/") : args.path
throw new Error(
`Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`,
)
})
},
})
return modules
}
export function createForeignPackageFilter(rootsInput: Iterable<string> = resolveHostPackageRoots()) {
const suffix = String.raw`[/\\]${foreignPkgSuffix}[/\\].*\.[cm]?[jt]sx?(?:[?#].*)?$`
const roots = [...new Set(rootsInput)]
if (roots.length === 0) return new RegExp(suffix)
const escaped = roots
.map((value) =>
value
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/(?:\\\/|\\\\|\/)+/g, "[/\\\\]"),
)
.join("|")
return new RegExp(`^(?!(?:${escaped})[/\\\\]).*${suffix}`)
}
-2
View File
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Host } from "./host.js"
import { provides } from "./runtime.js"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
@@ -40,7 +39,6 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
? new URL(item.path, pathToFileURL(file))
: localSource(item.path, path.dirname(file))
if (!local) {
if (provides(item.path)) continue
try {
Bun.resolveSync(item.path, path.dirname(file))
} catch {
+2 -2
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
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"
@@ -11,7 +11,7 @@ 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 realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-")))
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]) => {
+16
View File
@@ -0,0 +1,16 @@
/**
* Clients send the API version they can decode in this header. The server omits response variants newer than
* that version, so a newer server does not break an older client that rejects unknown union members.
*/
export const API_VERSION_HEADER = "x-opencode-api-version"
/**
* Response-shape versions:
*
* - `1`: clients that send no header (v2.0.24 and earlier).
* - `2`: understands `external` integration methods, connections, and credentials.
*
* Bump this when a response can contain a value an older client cannot decode, and omit that value on the server
* for requests below the new version.
*/
export const API_VERSION = 2
+33
View File
@@ -0,0 +1,33 @@
export * as ApiVersion from "./api-version"
import type { Integration } from "@opencode/core/integration"
import { API_VERSION_HEADER } from "@opencode/protocol/api-version"
import type { Credential } from "@opencode/schema/credential"
import type { HttpServerRequest } from "effect/unstable/http"
// Responses omit values newer than the requesting client's API version. Remove a version's
// omissions once clients below it are no longer supported.
/** Returns the API version the requesting client can decode. Clients that send no header predate versioning. */
export function requested(request: HttpServerRequest.HttpServerRequest) {
const version = Number(request.headers[API_VERSION_HEADER])
return Number.isSafeInteger(version) && version > 0 ? version : 1
}
/** Version 1 clients reject `external` integration methods and connections. */
export function integration(info: Integration.Info, version: number): Integration.Info {
if (version >= 2) return info
return {
...info,
methods: info.methods.filter((method) => method.type !== "external"),
connections: info.connections.filter(
(connection) => connection.type !== "credential" || connection.method !== "external",
),
}
}
/** Version 1 clients reject `external` credential values. */
export function credentials<T extends { readonly value: Credential.Value }>(entries: T[], version: number) {
if (version >= 2) return entries
return entries.filter((entry) => entry.value.type !== "external")
}
+3 -2
View File
@@ -3,14 +3,15 @@ import { ConflictError } from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ApiVersion } from "../api-version"
export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", (handlers) =>
handlers
.handle(
"credential.list",
Effect.fn(function* () {
Effect.fn(function* (ctx) {
const credential = yield* Credential.Service
return { data: entries(yield* credential.all()) }
return { data: ApiVersion.credentials(entries(yield* credential.all()), ApiVersion.requested(ctx.request)) }
}),
)
.handle(
+7 -3
View File
@@ -11,6 +11,7 @@ import {
} from "@opencode/protocol/errors"
import { response } from "../location"
import { WellKnown } from "@opencode/core/wellknown"
import { ApiVersion } from "../api-version"
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
effect.pipe(
@@ -29,10 +30,13 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
return handlers
.handle(
"integration.list",
Effect.fn(function* () {
Effect.fn(function* (ctx) {
yield* Plugin.awaitActivation
const service = yield* Integration.Service
return yield* response(service.list())
const version = ApiVersion.requested(ctx.request)
return yield* response(
service.list().pipe(Effect.map((list) => list.map((item) => ApiVersion.integration(item, version)))),
)
}),
)
.handle(
@@ -45,7 +49,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
integrationID: ctx.params.integrationID,
message: `Integration not found: ${ctx.params.integrationID}`,
})
return yield* response(Effect.succeed(integration))
return yield* response(Effect.succeed(ApiVersion.integration(integration, ApiVersion.requested(ctx.request))))
}),
)
.handle(
+1 -3
View File
@@ -47,7 +47,6 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
options: ServerOptions,
lifecycle?: Lifecycle<E, R>,
transform?: Transform,
remoteURLs?: () => ReadonlyArray<string>,
) {
const password = options.password
if (!password) return yield* Effect.fail(new Error("Missing server password"))
@@ -56,13 +55,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const shutdown = yield* Latch.make()
const status = yield* Status.make()
const bound = yield* listen({ hostname, port })
const localURLs = () => {
const urls = () => {
const address = bound.server.address()
if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}
const urls = () => [...localURLs(), ...(remoteURLs?.() ?? [])]
const application = yield* Ref.make(Option.none<App>())
const app = dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp)
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
+92
View File
@@ -0,0 +1,92 @@
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { Plugin } from "@opencode/plugin/effect"
import { API_VERSION, API_VERSION_HEADER } from "@opencode/protocol/api-version"
import { Context, Effect, Layer } 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"
it.live(
"omits external integration methods, connections, and credentials for clients below API version 2",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-api-version-")
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
models: { fetch: false },
fs: { filewatcher: false },
config: { directory: tmp.path, project: false },
}).pipe(Layer.provide(HttpServer.layerServices)),
)
yield* Context.get(context, SdkPlugins.Service).register(
Plugin.define({
id: "external-fixture",
effect: (ctx) =>
ctx.integration.transform((editor) => {
editor.update("cloud", (integration) => {
integration.name = "Cloud"
})
editor.method.update({ integrationID: "cloud", method: { type: "key", label: "API key" } })
editor.method.update({
integrationID: "cloud",
method: { id: "profile", type: "external", label: "Profile" },
})
}),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const request = (route: string, input: { version?: number; body?: unknown } = {}) =>
Effect.promise(async (signal) => {
const url = new URL(route, "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
const headers = new Headers({ authorization: `Basic ${btoa("opencode:secret")}` })
if (input.version !== undefined) headers.set(API_VERSION_HEADER, String(input.version))
if (input.body !== undefined) headers.set("content-type", "application/json")
const response = await handler(
new Request(url, {
method: input.body === undefined ? "GET" : "POST",
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
signal,
}),
)
expect(response.status).toBeLessThan(300)
return response.json()
})
yield* request("/api/credential", {
version: API_VERSION,
body: { integrationID: "cloud", label: "Default profile", value: { type: "external", methodID: "profile" } },
})
yield* request("/api/credential", {
version: API_VERSION,
body: { integrationID: "cloud", label: "Key", value: { type: "key", key: "secret" }, activate: false },
})
const cloud = (body: { data: Array<{ id: string }> }) => body.data.find((item) => item.id === "cloud")
expect(cloud(yield* request("/api/integration", { version: API_VERSION }))).toMatchObject({
methods: [{ type: "key" }, { type: "external", id: "profile" }],
connections: expect.arrayContaining([expect.objectContaining({ method: "external" })]),
})
expect((yield* request("/api/credential", { version: API_VERSION })).data).toHaveLength(2)
// v2.0.24 and earlier send no version header; a malformed header is treated the same way.
for (const version of [undefined, 1, Number.NaN]) {
const legacy = cloud(yield* request("/api/integration", { version }))
expect(legacy).toMatchObject({ methods: [{ type: "key" }] })
expect(legacy).not.toMatchObject({ methods: expect.arrayContaining([{ type: "external" }]) })
expect(JSON.stringify(legacy)).not.toContain('"external"')
expect(JSON.stringify(yield* request("/api/integration/cloud", { version }))).not.toContain('"external"')
expect(yield* request("/api/credential", { version })).toMatchObject({
data: [{ integrationID: "cloud", value: { type: "key" } }],
})
}
}),
15_000,
)
-23
View File
@@ -236,26 +236,3 @@ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expect
if (new TextDecoder().decode(next.value).includes(expected)) return
}
}
it.live("server info lists remote URLs alongside local addresses while they are available", () =>
Effect.gen(function* () {
const remote = { urls: ["https://example.opentunnel.xyz"] as ReadonlyArray<string> }
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
undefined,
() => remote.urls,
)
const base = HttpServer.formatAddress(server.address)
const info = () =>
Effect.promise(() =>
fetch(new URL("/api/info", base), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }).then(
(response) => response.json() as Promise<{ urls: ReadonlyArray<string> }>,
),
)
expect((yield* info()).urls).toEqual([base, "https://example.opentunnel.xyz"])
remote.urls = []
expect((yield* info()).urls).toEqual([base])
}),
)
@@ -817,8 +817,6 @@
[data-component="tool-part-wrapper"] {
width: 100%;
/* Revealing a tool lands its title just below the headers that stick above it. */
scroll-margin-top: var(--sticky-accordion-top, 0px);
}
[data-component="dock-prompt"][data-kind="permission"] {
@@ -1,11 +1,8 @@
import { ensurePluginRuntime, provides } from "@opencode/plugin/runtime"
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
ensurePluginRuntime()
ensureRuntimePluginSupport({
additional: {
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
},
preserve: provides,
})
-100
View File
@@ -1,5 +1,4 @@
import { expect, test } from "bun:test"
import { createRequire } from "node:module"
import path from "node:path"
import { mkdir, rename, symlink } from "node:fs/promises"
import { fileURLToPath, pathToFileURL } from "node:url"
@@ -8,7 +7,6 @@ import "../src/plugin/runtime-plugin-support.bun"
import { createPluginSources } from "../src/plugin/source"
import { createSourceWatcher } from "../src/plugin/watch"
import { createSignal } from "solid-js"
import { Effect, Option, Schema } from "effect"
import { Plugin } from "@opencode/plugin/tui"
import { tmpdir } from "./fixture/fixture"
@@ -140,104 +138,6 @@ test("shared runtime and ordinary package identities survive plugin generations"
}
})
test("TUI plugins importing @opencode/plugin/tui and solid-js alongside effect resolve effect and effect/* to the host copy without rewriting host Effect or hijacking plugin dependencies", async () => {
const effectPlugin = await import("@opencode/plugin/effect")
const req = createRequire(import.meta.url)
expect(req("effect").Effect).toBe(Effect)
expect(req("effect/Option").some).toBe(Option.some)
expect(req("@opencode/plugin/effect").Plugin).toBe(effectPlugin.Plugin)
expect(JSON.stringify(Schema.Option(Schema.String).ast)).not.toContain("opentui:runtime-module:")
const standalone = Bun.spawnSync(
[
process.execPath,
"-e",
[
`import ${JSON.stringify(fileURLToPath(new URL("../src/plugin/runtime-plugin-support.bun.ts", import.meta.url)))}`,
'import assert from "node:assert/strict"',
'import { createRequire } from "node:module"',
"const req = createRequire(import.meta.url)",
'const eff = req("effect")',
'const opt = req("effect/Option")',
'const plug = req("@opencode/plugin/effect")',
'const { Effect } = await import("effect")',
'const { some } = await import("effect/Option")',
'const { Plugin } = await import("@opencode/plugin/effect")',
"assert.equal(eff.Effect, Effect)",
"assert.equal(opt.some, some)",
"assert.equal(plug.Plugin, Plugin)",
].join("\n"),
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
expect({ stderr: standalone.stderr.toString(), exit: standalone.exitCode }).toEqual({ stderr: "", exit: 0 })
const watched: string[] = []
await using sources = await fixture(async (file) => {
watched.push(file)
})
const entry = new URL("tui.ts", sources.url)
const badEntry = new URL("bad-tui.ts", sources.url)
await Promise.all(
Object.entries({
"node_modules/effect/package.json":
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":{"import":"./dist/esm/index.js"},"./Option":{"import":"./dist/esm/Option.js"},"./RemovedSubpath":{"import":"./dist/esm/RemovedSubpath.js"},"./package.json":"./package.json"}}',
"node_modules/effect/dist/esm/index.js":
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
"node_modules/effect/dist/esm/Option.js": "export const some = () => null",
"node_modules/effect/dist/esm/RemovedSubpath.js": "export const removed = true",
"node_modules/effect-helper/package.json":
'{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}',
"node_modules/effect-helper/index.js":
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const helper = { Effect, Schema, some }',
"node_modules/zod/package.json": '{"name":"zod","type":"module","exports":{".":"./index.js"}}',
"node_modules/zod/index.js": "export const fromPluginZod = true",
"helper.ts": ['import { fromPluginZod }', 'from "zod"; export { fromPluginZod }'].join(" "),
"tui.ts": [
"import { createSignal }",
'from "solid-js"',
"import { Plugin }",
'from "@opencode/plugin/tui"',
'import { Plugin as HostEffectPlugin } from "@opencode/plugin/effect"',
'import { Effect, Schema } from "effect"',
'import { some } from "effect/Option"',
"import pkg",
'from "effect/package.json" with { type: "json" }',
'import { helper } from "effect-helper"',
'const { fromPluginZod } = await import("./helper.ts")',
'const dynOption = await import("effect/Option")',
'const dynEffectPlugin = await import("@opencode/plugin/effect")',
"export const plugin = { createSignal, Plugin, HostEffectPlugin, Effect, Schema, some, dynSome: dynOption.some, dynEffectPlugin: dynEffectPlugin.Plugin, pkgName: pkg.name, fromPluginZod }",
"export { helper }",
].join("\n"),
"bad-tui.ts": [
"import { Plugin }",
'from "@opencode/plugin/tui"; import { removed } from "effect/RemovedSubpath"; export default { Plugin, removed }',
].join(" "),
}).map(([file, text]) => Bun.write(new URL(file, sources.url), text)),
)
const loaded = (await sources.read(entry.href)).module as {
plugin: Record<string, unknown>
helper: Record<string, unknown>
}
expect(loaded.plugin.createSignal).toBe(createSignal)
expect(loaded.plugin.Plugin).toBe(Plugin)
expect(loaded.plugin.HostEffectPlugin).toBe(effectPlugin.Plugin)
expect(loaded.plugin.Effect).toBe(Effect)
expect(loaded.plugin.Schema).toBe(Schema)
expect(loaded.plugin.some).toBe(Option.some)
expect(loaded.plugin.dynSome).toBe(Option.some)
expect(loaded.plugin.dynEffectPlugin).toBe(effectPlugin.Plugin)
expect(loaded.plugin.pkgName).toBe("effect")
expect(loaded.plugin.fromPluginZod).toBe(true)
expect(loaded.helper.Effect).toBe(Effect)
expect(loaded.helper.Schema).toBe(Schema)
expect(loaded.helper.some).toBe(Option.some)
expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/effect"))).toBe(false)
expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/@opencode/plugin"))).toBe(false)
await expect(sources.read(badEntry.href)).rejects.toThrow("effect/dist/esm/RemovedSubpath.js")
})
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
-1
View File
@@ -109,7 +109,6 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-listener": "catalog:",
+5 -5
View File
@@ -1,8 +1,12 @@
@import "../submit.css";
[data-component="button-v2"] {
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="button-v2"] {
position: relative;
display: inline-flex;
align-items: center;
@@ -22,10 +26,6 @@
outline: none;
}
[data-component="button-v2"] > * {
pointer-events: none;
}
[data-component="button-v2"]:focus {
outline: none;
}
@@ -1,8 +1,12 @@
@import "../submit.css";
[data-component="icon-button-v2"] {
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="icon-button-v2"] {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -3,8 +3,6 @@
}
[data-slot="segmented-control-v2"] {
position: relative;
isolation: isolate;
box-sizing: border-box;
display: flex;
flex-direction: row;
@@ -18,28 +16,9 @@
border-radius: 6px;
box-shadow: 0 0 0 0.5px var(--v2-border-border-base);
flex: none;
user-select: none;
-webkit-user-select: none;
}
[data-slot="segmented-control-v2-indicator"] {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 6px;
background: var(--v2-background-bg-base);
box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
pointer-events: none;
user-select: none;
-webkit-user-select: none;
z-index: 0;
will-change: transform, width;
}
[data-slot="segmented-control-v2-item"] {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -55,26 +34,20 @@
background: transparent;
box-shadow: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 100%;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-muted);
transition:
color 0.15s ease,
background-color 0.12s ease,
color 0.12s ease,
box-shadow 0.12s ease;
}
[data-slot="segmented-control-v2-item"]:where(:hover:not(:disabled):not([data-pressed])) {
color: var(--v2-text-text-base);
}
[data-slot="segmented-control-v2-item"]:where(:disabled) {
cursor: not-allowed;
opacity: 0.45;
@@ -87,13 +60,10 @@
}
[data-slot="segmented-control-v2-item"]:where([data-pressed]) {
color: var(--v2-text-text-base);
z-index: 1;
}
[data-slot="segmented-control-v2"]:not([data-ready]) [data-slot="segmented-control-v2-item"]:where([data-pressed]) {
background: var(--v2-background-bg-base);
color: var(--v2-text-text-base);
box-shadow: 0 0 0 0.5px var(--v2-border-border-strong);
z-index: 1;
}
[data-slot="segmented-control-v2-item-label"] {
@@ -107,7 +77,4 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
@@ -1,55 +0,0 @@
import { describe, expect, test } from "bun:test"
import { measureOffset, SEGMENTED_CONTROL_SPRING, type OffsetElement } from "./segmented-control"
describe("SegmentedControl", () => {
test("uses snappier non-bouncy spring default", () => {
expect(SEGMENTED_CONTROL_SPRING).toEqual({
type: "spring",
visualDuration: 0.15,
bounce: 0,
})
})
test("measures button offset along offsetParent chain to root", () => {
const root: OffsetElement = {
offsetLeft: 0,
offsetWidth: 232,
offsetParent: null,
getBoundingClientRect: () => ({ left: 0 }),
}
const wrapper: OffsetElement = {
offsetLeft: 8,
offsetWidth: 216,
offsetParent: root,
getBoundingClientRect: () => ({ left: 8 }),
}
const button: OffsetElement = {
offsetLeft: 44,
offsetWidth: 76,
offsetParent: wrapper,
getBoundingClientRect: () => ({ left: 52 }),
}
expect(measureOffset(root, button)).toEqual({ x: 52, width: 76 })
})
test("falls back to bounding client rect difference when offsetParent does not reach root", () => {
const root: OffsetElement = {
offsetLeft: 0,
offsetWidth: 232,
offsetParent: null,
getBoundingClientRect: () => ({ left: 100 }),
}
const button: OffsetElement = {
offsetLeft: 0,
offsetWidth: 64,
offsetParent: null,
getBoundingClientRect: () => ({ left: 164 }),
}
expect(measureOffset(root, button)).toEqual({ x: 64, width: 64 })
})
})
@@ -1,37 +1,25 @@
import { animate, motion, useMotionValue, type Transition } from "@kitlangton/solid-motion"
import {
createContext,
createEffect,
createMemo,
createSignal,
mergeProps,
onCleanup,
onMount,
Show,
splitProps,
useContext,
type Accessor,
type ComponentProps,
type JSX,
type ParentProps,
} from "solid-js"
import type { ComponentProps } from "solid-js"
import "./segmented-control.css"
type OnChange = (value: string | null) => void
export const SEGMENTED_CONTROL_SPRING: Transition = {
type: "spring",
visualDuration: 0.15,
bounce: 0,
}
type SegmentedControlContextValue = {
selected: Accessor<string | null>
groupDisabled: Accessor<boolean>
select: (value: string) => void
clearIfAllowed: (value: string) => void
focusNext: (from: HTMLButtonElement, direction: 1 | -1) => void
registerItem: (value: string, el: HTMLButtonElement) => () => void
}
const SegmentedControlContext = createContext<SegmentedControlContextValue>()
@@ -44,33 +32,6 @@ function useSegmentedControlContext() {
return ctx
}
export type OffsetElement = {
offsetLeft: number
offsetWidth: number
offsetParent: Element | OffsetElement | null
getBoundingClientRect: () => { left: number }
}
function isOffsetElement(node: Element | OffsetElement | null): node is OffsetElement {
return node !== null && "offsetLeft" in node && "offsetParent" in node
}
export function measureOffset(root: OffsetElement, button: OffsetElement) {
let offset = 0
let node: OffsetElement | null = button
while (node && node !== root) {
offset += node.offsetLeft
node = isOffsetElement(node.offsetParent) ? node.offsetParent : null
}
if (node !== root) {
offset = button.getBoundingClientRect().left - root.getBoundingClientRect().left
}
return { x: offset, width: button.offsetWidth }
}
export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> &
ParentProps<{
/** Selected value when controlled (including `null` when empty). Omit key for uncontrolled. */
@@ -81,12 +42,10 @@ export type SegmentedControlProps = Omit<ComponentProps<"div">, "onChange"> &
/** When true, clicking the active segment clears selection (`onChange(null)`). Default false. */
allowDeselect?: boolean
disabled?: boolean
/** Motion transition for the active indicator pill. */
transition?: Transition
}>
export function SegmentedControl(props: SegmentedControlProps) {
const isControlled = createMemo(() => Object.hasOwn(props, "value"))
const isControlled = createMemo(() => Object.hasOwn(props as object, "value"))
const merged = mergeProps({ allowDeselect: false, disabled: false }, props)
const [local, rest] = splitProps(merged, [
@@ -98,88 +57,13 @@ export function SegmentedControl(props: SegmentedControlProps) {
"onChange",
"allowDeselect",
"disabled",
"transition",
"ref",
])
const [internal, setInternal] = createSignal<string | null>(local.defaultValue ?? null)
const [layoutVersion, setLayoutVersion] = createSignal(0)
const [ready, setReady] = createSignal(false)
const selected = createMemo(() => (isControlled() ? (local.value ?? null) : internal()))
const reducedMotion = () => globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true
const x = useMotionValue(0)
const width = useMotionValue(0)
let rootEl: HTMLDivElement | undefined
let observer: ResizeObserver | undefined
const items = new Map<string, HTMLButtonElement>()
let target = { x: 0, width: 0 }
let lastSelected: string | null = null
const syncIndicator = (shouldAnimate: boolean) => {
const current = selected()
const button = current !== null ? items.get(current) : undefined
if (!rootEl || !button) {
setReady(false)
return
}
const next = measureOffset(rootEl, button)
if (next.width === 0) return
target = next
if (!ready() || !shouldAnimate || reducedMotion()) {
x.jump(next.x)
width.jump(next.width)
setReady(true)
return
}
const transition = local.transition ?? SEGMENTED_CONTROL_SPRING
animate(x, next.x, transition)
animate(width, next.width, transition)
}
onMount(() => {
if (!("ResizeObserver" in globalThis)) return
observer = new ResizeObserver(() => {
const current = selected()
const button = current !== null ? items.get(current) : undefined
if (!rootEl || !button) return
const next = measureOffset(rootEl, button)
if (next.width === 0) return
if (ready() && next.x === target.x && next.width === target.width) return
syncIndicator(false)
})
if (rootEl) observer.observe(rootEl)
items.forEach((button) => observer?.observe(button))
onCleanup(() => observer?.disconnect())
})
createEffect(() => {
const current = selected()
layoutVersion()
const shouldAnimate = ready() && lastSelected !== null && current !== null && current !== lastSelected
lastSelected = current
syncIndicator(shouldAnimate)
})
const setSelected = (next: string | null) => {
if (!isControlled()) setInternal(next)
local.onChange?.(next)
@@ -208,32 +92,19 @@ export function SegmentedControl(props: SegmentedControlProps) {
next?.focus()
}
const registerItem = (value: string, el: HTMLButtonElement) => {
items.set(value, el)
observer?.observe(el)
setLayoutVersion((v) => v + 1)
return () => {
if (items.get(value) === el) items.delete(value)
observer?.unobserve(el)
setLayoutVersion((v) => v + 1)
}
}
const ctx: SegmentedControlContextValue = {
selected,
groupDisabled: () => !!local.disabled,
select,
clearIfAllowed,
focusNext,
registerItem,
}
const assignRef = (el: HTMLDivElement) => {
rootEl = el
const assignRef = (el: HTMLDivElement | undefined) => {
const r = local.ref
if (r instanceof Function) r(el)
if (typeof r === "function") (r as (el: HTMLDivElement | undefined) => void)(el)
else if (r != null && typeof r === "object" && "value" in r) (r as { value: HTMLDivElement | undefined }).value = el
}
return (
@@ -244,15 +115,11 @@ export function SegmentedControl(props: SegmentedControlProps) {
role="group"
data-component="segmented-control-v2"
data-slot="segmented-control-v2"
data-ready={ready() ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Show when={ready() && selected() !== null}>
<motion.span data-slot="segmented-control-v2-indicator" aria-hidden="true" style={{ x, width }} />
</Show>
{local.children}
</div>
</SegmentedControlContext.Provider>
@@ -267,17 +134,9 @@ export type SegmentedControlItemProps = Omit<ComponentProps<"button">, "type" |
function invokeButtonHandler<E extends Event>(
handler: JSX.EventHandlerUnion<HTMLButtonElement, E> | undefined,
e: Parameters<JSX.EventHandler<HTMLButtonElement, E>>[0],
e: E & { currentTarget: HTMLButtonElement },
) {
if (handler instanceof Function) {
handler(e)
return
}
if (Array.isArray(handler)) {
handler[0](handler[1], e)
}
if (typeof handler === "function") (handler as (ev: typeof e) => void)(e)
}
export function SegmentedControlItem(props: SegmentedControlItemProps) {
@@ -291,18 +150,10 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
"disabled",
"onClick",
"onKeyDown",
"ref",
])
const ctx = useSegmentedControlContext()
let buttonEl: HTMLButtonElement | undefined
createEffect(() => {
if (!buttonEl) return
const cleanup = ctx.registerItem(local.value, buttonEl)
onCleanup(cleanup)
})
const pressed = createMemo(() => ctx.selected() === local.value)
const disabled = createMemo(() => ctx.groupDisabled() || !!local.disabled)
@@ -311,38 +162,27 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
if (e.defaultPrevented || disabled()) return
if (pressed()) {
ctx.clearIfAllowed(local.value)
return
}
ctx.select(local.value)
if (pressed()) ctx.clearIfAllowed(local.value)
else ctx.select(local.value)
}
const onKeyDown: JSX.EventHandlerUnion<HTMLButtonElement, KeyboardEvent> = (e) => {
invokeButtonHandler(local.onKeyDown, e)
if (e.defaultPrevented || disabled()) return
const t = e.currentTarget
const horizontal = t.closest(`[data-slot="segmented-control-v2"]`)?.matches(":dir(rtl)") ? -1 : 1
if (e.key === "ArrowRight") {
e.preventDefault()
ctx.focusNext(t, horizontal)
return
}
if (e.key === "ArrowLeft") {
} else if (e.key === "ArrowLeft") {
e.preventDefault()
ctx.focusNext(t, horizontal === 1 ? -1 : 1)
return
ctx.focusNext(t, -horizontal as 1 | -1)
}
if (e.key === "Home") {
// accessibility stuff
else if (e.key === "Home") {
e.preventDefault()
const root = t.closest(`[data-slot="segmented-control-v2"]`)
@@ -351,11 +191,7 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
)
first?.focus()
return
}
if (e.key === "End") {
} else if (e.key === "End") {
e.preventDefault()
const root = t.closest(`[data-slot="segmented-control-v2"]`)
@@ -368,17 +204,9 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
}
}
const assignRef = (el: HTMLButtonElement) => {
buttonEl = el
const r = local.ref
if (r instanceof Function) r(el)
}
return (
<button
{...rest}
ref={assignRef}
type="button"
data-slot="segmented-control-v2-item"
data-pressed={pressed() ? "" : undefined}
@@ -395,11 +223,3 @@ export function SegmentedControlItem(props: SegmentedControlItemProps) {
</button>
)
}
export const SegmentedControlV2 = SegmentedControl
export const SegmentedControlItemV2 = SegmentedControlItem
export type SegmentedControlV2Props = SegmentedControlProps
export type SegmentedControlItemV2Props = SegmentedControlItemProps
@@ -1,6 +1,10 @@
[data-component="tabs-v2"] {
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="tabs-v2"] {
width: 100%;
height: 100%;
display: flex;
@@ -3,14 +3,11 @@ title: "Effect"
---
`@opencode/plugin/effect` is the Effect-native version of the OpenCode plugin API. Its context operations return
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`.
OpenCode provides `effect` (including exported `effect/*` subpaths) and `@opencode/plugin` at runtime when loading
plugins and their `node_modules` dependencies. Install them as peer/dev dependencies for local type-checking and testing
rather than bundling them into your plugin.
Effects or Streams, callbacks return Effects, and plugin lifetime is represented by `Scope`. Install `effect` with the
plugin package.
```sh
bun add --peer @opencode/plugin effect
bun add @opencode/plugin effect
```
Export an Effect plugin from `.opencode/plugins/` to load it automatically.
@@ -1503,7 +1500,7 @@ type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Regi
## Publish
A package plugin uses the same default export as a local Effect plugin. Export the Effect implementation from the main
entrypoint and declare `effect` as a `peerDependency`.
entrypoint and declare both runtime dependencies.
```json title="package.json"
{
@@ -1514,28 +1511,12 @@ entrypoint and declare `effect` as a `peerDependency`.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest"
},
"peerDependencies": {
"effect": ">=4.0.0-rc.112"
"@opencode/plugin": "latest",
"effect": "4.0.0-rc.111"
}
}
```
OpenCode redirects imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` (including imports from
dependencies in the plugin's `node_modules`) to the host's runtime instances so fibers, loggers, and `Schema` parsers
share one runtime copy.
- Do not bundle `effect` into the published plugin artifact. If you build with a bundler, mark `effect` and `effect/*`
external.
- Plugins and their `node_modules` dependencies always execute against the host OpenCode release's `effect` instance.
Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another
`effect` major (such as Effect 3) also receive the host's `effect` and are not supported.
- Only public `effect` subpaths are provided. A plugin that imports one of Effect's private `internal` modules fails to
load with an error naming the path.
- The compiled OpenCode binary does not include the Scalar and Swagger UI assets used by Effect's HTTP API docs pages;
serving those pages from a plugin shows a notice instead.
The package entrypoint exports `Plugin.define` with an `effect` function.
```ts title="src/index.ts"
@@ -66,7 +66,6 @@ You can also access the models directly through the following API endpoints.
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 5.5 | claude-sonnet-5-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 5 | claude-sonnet-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -248,7 +247,6 @@ Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Sonnet 5.5 | $2.00 | $10.00 | $0.20 | $2.50 |
| Claude Sonnet 5 | $2.00 | $10.00 | $0.20 | $2.50 |
| Claude Sonnet 4.6 | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |