Compare commits

..
Author SHA1 Message Date
LukeParkerDev 713802f907 fix(sdk): resolve packaged worker paths on Windows 2026-09-03 08:31:27 +10:00
LukeParkerDev c0bbec60fd fix(plugin-browser): keep release helpers out of the package 2026-09-03 08:29:03 +10:00
LukeParkerDev c95327bb97 refactor(browser): extract the plugin into its own package 2026-09-03 08:29:02 +10:00
LukeParkerDev 38ced14b61 fix(browser): let a newer attachment replace a stale one 2026-09-03 08:29:00 +10:00
LukeParkerDev e5d410f4cb refactor(browser): use canonical public schemas 2026-09-03 08:28:59 +10:00
LukeParkerDev e97f5ed2ed refactor(browser): defer permission enforcement 2026-09-03 08:28:58 +10:00
LukeParkerDev b886045b40 refactor(browser): colocate the public API plugin 2026-09-03 08:28:56 +10:00
LukeParkerDev 2382da0c2b feat(browser): add public RPC browser plugin 2026-09-03 08:28:55 +10:00
Dax Raad 68a40e2d21 chore: remove package reservation script 2026-09-02 18:15:53 -04:00
Dax 57c02cd04b fix(tui): refresh plugin dialog after updates (#46911) 2026-09-02 17:59:18 -04:00
Dax Raad 46515df4a3 refactor(updates): use useragent event field 2026-09-02 17:51:55 -04:00
Dax Raad 51250e4346 feat(updates): include client IP in request events 2026-09-02 17:51:33 -04:00
Aiden Cline dbd47702b2 fix(core): tell plan agent to discuss plans instead of writing files by default (#46905) 2026-09-02 16:49:04 -05:00
Dax Raad a085bf62a4 feat(updates): log requests to the shared data lake 2026-09-02 17:25:02 -04:00
Luke Parker b605f355ca fix(core): share the models.dev snapshot across Locations (#46784) 2026-09-03 07:22:07 +10:00
opencode-agent[bot] 85e2b0a23a chore: update nix node_modules hashes 2026-09-02 21:21:54 +00:00
Kit Langton 3dc187bf3f refactor(client): share location resource reads (#46831) 2026-09-02 17:05:09 -04:00
Dax ed3259a9b7 refactor(plugin): centralize host resolution (#46901) 2026-09-02 17:04:15 -04:00
66 changed files with 1840 additions and 1050 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
if: github.ref_name == 'v2'
working-directory: packages/updates
run: bun run deploy
env:
+21 -4
View File
@@ -358,6 +358,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -575,6 +576,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -585,6 +587,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -603,6 +606,21 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -672,6 +690,7 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -971,7 +990,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1123,7 +1141,6 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -2143,6 +2160,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -5171,8 +5190,6 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
}
}
-2
View File
@@ -27,7 +27,6 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -93,7 +92,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
@@ -89,12 +89,7 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
packages: {
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -5,6 +5,7 @@ import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
@@ -16,9 +17,9 @@ export default Runtime.handler(
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
const installed = yield* npm.add(input.package)
const entrypoints = Host.resolve(installed)
const target = configurationTarget(entrypoints.server, entrypoints.tui)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const installed = yield* npm.resolve(target)
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,27 +23,27 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (local) {
const directory = fileURLToPath(local)
const entrypoints = Host.resolve({ directory })
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
const installed = yield* npm.resolve(target)
if (!Host.resolve(installed).tui) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
const output = format(response.data, configured.flat(), input.builtin)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
@@ -81,9 +82,7 @@ export function format(
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
@@ -26,7 +26,7 @@ export default Runtime.handler(
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
: npm.update(item.target).pipe(Effect.asVoid)
).pipe(
Effect.exit,
Effect.map((result) => ({ item, result })),
@@ -985,15 +985,6 @@ export type ReferenceUpdated = {
data: {}
}
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
}
export type PluginUpdated = {
id: string
created: number
@@ -2299,7 +2290,6 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
+55 -202
View File
@@ -83,10 +83,8 @@ type LocationData = {
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcp?: {
server?: McpServer[]
resource?: McpResource[]
}
mcpServer?: McpServer[]
mcpResource?: McpResource[]
model?: ModelInfo[]
provider?: ProviderInfo[]
reference?: ReferenceInfo[]
@@ -125,8 +123,8 @@ export function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
function locationQuery(ref: LocationRef) {
return { directory: ref.directory, workspace: ref.workspaceID }
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
@@ -1273,6 +1271,41 @@ export function createData(config: CreateDataInput) {
}
}
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
// effective location, and publishes under the server's canonical location; `alias`
// also publishes under the requested key when the two differ.
function locationResource<Field extends keyof LocationData>(
field: Field,
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
options?: { alias?: boolean },
) {
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
return {
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
sync: (ref?: LocationRef) => {
const location = ref ?? defaultLocation()
const id = locationKey(location)
return sync.run(`location.${field}:${id}`, async () => {
const response = await load(locationQuery(location))
const key = locationKey(response.location)
publish(key, response.data)
if (options?.alias && key !== id) publish(id, response.data)
})
},
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
}
}
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
const shells = locationResource("shell", async (location) => {
const response = await api().shell.list({ location })
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
return {
location: response.location,
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
}
})
const result = {
on: config.event.on,
listen: config.event.listen,
@@ -1740,7 +1773,7 @@ export function createData(config: CreateDataInput) {
},
shell: {
list(location?: LocationRef) {
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
return Object.values(shells.list(location) ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
@@ -1752,31 +1785,8 @@ export function createData(config: CreateDataInput) {
.map((data) => data.shell?.[id])
.find((shell) => shell !== undefined)
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.shell:${id}`, async () => {
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
},
sync: shells.sync,
invalidate: shells.invalidate,
},
location: {
info(ref?: LocationRef) {
@@ -1831,162 +1841,20 @@ export function createData(config: CreateDataInput) {
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await api().vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.agent:${id}`, async () => {
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], agent: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
},
},
command: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.command
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.command:${id}`, async () => {
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], command: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
},
},
integration: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.integration:${id}`, async () => {
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], integration: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
},
},
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
integration: locationResource("integration", (location) => api().integration.list({ location })),
mcp: {
server: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.server:${id}`, async () => {
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, server: response.data },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
},
},
resource: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.resource:${id}`, async () => {
const response = await api().mcp.resource.catalog({
location: locationQuery(ref ?? defaultLocation()),
})
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
},
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.model:${id}`, async () => {
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], model: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
},
},
provider: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.provider:${id}`, async () => {
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], provider: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.reference:${id}`, async () => {
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], reference: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
},
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
resource: locationResource("mcpResource", async (location) => {
const response = await api().mcp.resource.catalog({ location })
return { location: response.location, data: response.data.resources }
}),
},
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
reference: locationResource("reference", (location) => api().reference.list({ location })),
websearch: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearch
@@ -2001,22 +1869,7 @@ export function createData(config: CreateDataInput) {
})
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.skill:${id}`, async () => {
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], skill: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
},
}
@@ -20378,44 +20378,6 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.added": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["plugin.added"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.updated": {
"type": "object",
"properties": {
@@ -23123,9 +23085,6 @@
{
"$ref": "#/components/schemas/permission.v2.replied"
},
{
"$ref": "#/components/schemas/plugin.added"
},
{
"$ref": "#/components/schemas/plugin.updated"
},
+2
View File
@@ -20,6 +20,7 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun run script/test.ts",
@@ -121,6 +122,7 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
@@ -0,0 +1,210 @@
// Measures the heap retained by Location service graphs and by the models.dev
// catalog plugin. Everything runs against a temporary global directory with a
// temporary home, an in-memory database, no filesystem watchers, and no network,
// so it never touches a live server, database, or user configuration.
//
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
//
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
// highest heapUsed sampled without forcing GC and are reported separately.
import fs from "fs/promises"
import os from "os"
import path from "path"
import { heapStats } from "bun:jsc"
import { Effect, Layer, Logger, Scope } from "effect"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Bus } from "../src/bus"
import { Catalog } from "../src/catalog"
import { Database } from "../src/database/database"
import { Integration } from "../src/integration"
import { Location } from "../src/location"
import { LocationServiceMap } from "../src/location-service-map"
import { ModelsDev } from "../src/models-dev"
import { Plugin } from "../src/plugin"
import { ModelsDevPlugin } from "../src/plugin/models-dev"
import { AbsolutePath } from "../src/schema"
import { Watcher } from "../src/filesystem/watcher"
import { location } from "../test/fixture/location"
import { catalogHost, host, integrationHost } from "../test/plugin/host"
const args = process.argv.slice(2)
const flag = (name: string, fallback: number) => {
const index = args.indexOf(`--${name}`)
if (index === -1) return fallback
const value = Number(args[index + 1])
if (!Number.isInteger(value) || value < 1) {
console.error(`--${name} must be a positive integer`)
process.exit(1)
}
return value
}
const locationCount = flag("locations", 6)
const pluginCount = flag("plugins", 8)
const jsonIndex = args.indexOf("--json")
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
type Sample = { heapUsed: number; rss: number; objects: number }
const sample = (): Sample => {
Bun.gc(true)
Bun.gc(true)
const usage = process.memoryUsage()
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
}
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
const median = (values: ReadonlyArray<number>) => {
const sorted = values.toSorted((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
const upper = sorted[middle] ?? 0
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
}
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
const globalLayer = Global.layerWith({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "cache", "bin"),
log: path.join(root, "data", "log"),
repos: path.join(root, "data", "repos"),
})
const replacements = [
Global.node.replace(globalLayer),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
]
// One full Location graph per directory, retained for the rest of the run, the
// way a long-running server retains every directory a client has touched.
const locationsProgram = Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
const rss: number[] = []
let previous = before
for (let index = 0; index < locationCount; index++) {
const directory = path.join(root, "projects", `location-${index}`)
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const context = yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
.pipe(Scope.provide(scope))
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
yield* plugins.awaitActivation
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
rss.push(current.rss)
previous = current
}
const catalog = yield* Catalog.Service.pipe(
Effect.provideContext(
yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
.pipe(Scope.provide(scope)),
),
)
const models = yield* catalog.model.all()
const providers = yield* catalog.provider.all()
return {
before,
after: previous,
deltas,
rss,
catalog: { providers: providers.length, models: models.length },
}
}).pipe(Effect.scoped)
// The models.dev plugin alone, against a real Catalog and Integration state per
// instance, isolates the catalog-copy contribution from the rest of the graph.
const pluginProgram = Effect.gen(function* () {
const modelsDev = yield* ModelsDev.Service
const snapshot = yield* modelsDev.get()
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
let previous = before
for (let index = 0; index < pluginCount; index++) {
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location(Location.Ref.make({ directory }))),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
Location.node.replace(locationLayer),
...replacements,
]),
).pipe(Scope.provide(scope))
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
yield* ModelsDevPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
yield* catalog.model.all()
yield* integration.list()
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
previous = current
}
return {
before,
after: previous,
deltas,
snapshot: {
providers: snapshot.length,
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
},
}
}).pipe(Effect.scoped)
const program = Effect.gen(function* () {
const plugin = yield* pluginProgram.pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
)
const locations = yield* locationsProgram.pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
),
)
return { plugin, locations }
}).pipe(Effect.provide(Logger.layer([])))
const result = await Effect.runPromise(program)
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
console.log(
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
)
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
console.log(
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
)
console.log(
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
)
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
console.log(
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
)
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
if (jsonPath) {
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
await fs.writeFile(
jsonPath,
JSON.stringify(
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
null,
2,
),
)
}
+3 -1
View File
@@ -301,7 +301,9 @@ export const layer = (options?: Options) =>
const initial = yield* discover()
let configs = initial
const updates = yield* PubSub.unbounded<Watcher.Update>()
const ignore = Watcher.vendored
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
+37 -17
View File
@@ -3,10 +3,11 @@ export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Host } from "@opencode-ai/plugin/host"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
@@ -41,10 +42,8 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin directories can live outside config roots, where the
// config change feed cannot see them; watch those directories directly, with
// the same vendored-tree ignores as config roots, so a sibling module edit
// reloads the plugin exactly as it does under a config root.
// Configured local plugins can live outside config roots, where the
// config change feed cannot see them; watch those targets directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
@@ -59,9 +58,8 @@ export const layer = Layer.effect(
if (isPluginSource(entries, operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({
path: path.dirname(operation.target),
type: "directory",
ignore: Watcher.vendored,
path: operation.target,
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
})
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
@@ -154,18 +152,40 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
return Option.some<Operation>(operation)
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return PluginSourceDirectory.mtime(fs, operation.target).pipe(Effect.map((mtime) => ({ ...operation, mtime })))
})
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
if (!(yield* fs.existsSafe(operation.target))) return [operation]
const directory = yield* fs.isDir(operation.target)
const entrypoints: Host.Entrypoints = directory
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
: { server: pathToFileURL(operation.target).href }
if (!entrypoints.server) return []
if (directory) {
const root = yield* fs.resolve(operation.target)
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
if (!FSUtil.contains(root, server)) return []
}
const times = yield* Effect.forEach(
[
...Object.values(entrypoints)
.filter((entry) => entry !== undefined)
.map((entry) => fileURLToPath(entry)),
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
],
(entry) =>
fs.stat(entry).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return [{ ...operation, mtime: Math.max(...times) }]
}),
).pipe(Effect.map((operations) => operations.flat()))
})
function isPluginSource(entries: readonly Entry[], file: string) {
-4
View File
@@ -36,10 +36,6 @@ export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
// Vendored trees inside a watched root (a plugin's node_modules, a nested .git)
// produce event blizzards that can never change what the root discovers.
export const vendored: readonly string[] = ["node_modules", ".git", "**/{node_modules,.git}/**"]
export type Subscription = {
readonly unsubscribe: () => Promise<void>
/** Backend name for logging, e.g. "node" or "fs-events". */
-1
View File
@@ -52,7 +52,6 @@ const layer = Layer.effect(
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
+2
View File
@@ -77,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -188,6 +189,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
+8 -5
View File
@@ -10,6 +10,8 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
// The normalized snapshot is shared by every Location and only read here; the catalog
// receives copies below, so retaining a second copy per Location is unnecessary.
const loaded = { data: snapshots(yield* modelsDev.get()) }
yield* ctx.integration.transform((integrations) => {
for (const provider of loaded.data) {
@@ -32,7 +34,7 @@ export const ModelsDevPlugin = define({
yield* ctx.catalog.transform((catalog) => {
for (const provider of loaded.data) {
catalog.provider.update(provider.info.id, (draft) => {
Object.assign(draft, provider.info)
Object.assign(draft, copy(provider.info))
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
@@ -65,15 +67,16 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return copy(data).filter(
return data.filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
// magnitude faster than structuredClone's general graph walk on the startup path.
// The catalog owns and mutates its provider and model records in place, so every rebuild
// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain
// JSON, and a direct copy is an order of magnitude faster than structuredClone's general
// graph walk on the startup path.
function copy<T>(value: T): T {
if (Array.isArray(value)) return value.map(copy) as T
if (value !== null && typeof value === "object") {
+24 -45
View File
@@ -1,16 +1,15 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Host } from "@opencode-ai/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { stat } from "node:fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
import { PluginSourceDirectory } from "./source-directory.js"
const Module = Schema.Struct({
default: Schema.Union([
@@ -36,21 +35,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
const npm = yield* Npm.Service
const installed = local
? undefined
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
? yield* npm.resolve(operation.target)
: yield* npm.add(operation.target)
// Legacy auto-discovery still admits standalone server sources. Configured
// local plugins always arrive here as directories.
const entrypoints: Host.Entrypoints =
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
? { server: pathToFileURL(operation.target).href }
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
const entrypoint = entrypoints.server
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const mod = yield* Effect.promise(() => Host.load(source))
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
Effect.mapError(
(cause) =>
@@ -61,46 +66,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
features,
revision: JSON.stringify([operation, installed.revision]),
features: {
...(entrypoints.tui ? { tui: true as const } : {}),
...(entrypoints.rpc ? { rpc: true as const } : {}),
},
revision: JSON.stringify([operation, installed?.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
? { type: "local" as const, path: fileURLToPath(entrypoint) }
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
...(installed?.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
})
function localFeatures(entrypoint: string) {
const directory = PluginSourceDirectory.root(entrypoint)
if (!directory) return Effect.succeed({})
return Effect.promise(() => readdir(directory, { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { Permission } from "../permission.js"
const plan = Agent.ID.make("plan")
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. You may optionally create or update plan documents in:
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
${directory}
Do not modify any other files or ask a subagent to do so.
@@ -1,17 +1,16 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
const installedPath = packageName.startsWith("file://")
? packageName
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
: yield* npm.add(packageName).pipe(
Effect.orDie,
Effect.map((installed) => resolveModule(installed.name, installed.directory)),
)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const mod = (yield* Effect.promise(() => importModule(installedPath))) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
return mod[match]
+2 -64
View File
@@ -22,74 +22,12 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
if (entry.type === "directory") return Option.some(entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
if (yield* fs.isDir(entry.target)) return Option.some(entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
/** The directory a plugin entrypoint belongs to, or undefined for a single-file plugin. */
export function root(entrypoint: string) {
return path.basename(entrypoint).startsWith("index.") ? path.dirname(entrypoint) : undefined
}
/**
* Revision timestamp for a local plugin: the newest mtime across the directory
* for a directory plugin, so an edit to any sibling module reloads the whole
* unit, or the entrypoint's own mtime for a single-file plugin. Vendored trees
* cannot change the plugin and are skipped.
*/
export const mtime = Effect.fn("PluginSourceDirectory.mtime")(function* (fs: FSUtil.Interface, entrypoint: string) {
const directory = root(entrypoint)
const files = directory ? yield* walk(fs, directory) : [entrypoint]
const times = yield* Effect.forEach(files, (file) =>
fs.stat(file).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return Math.max(0, ...times)
})
const vendored = new Set(["node_modules", ".git"])
// Symlinks are stamped but never followed, so a linked directory cannot loop the walk.
// An unreadable directory contributes nothing rather than failing the revision.
function walk(fs: FSUtil.Interface, directory: string): Effect.Effect<string[]> {
return fs.readDirectoryEntries(directory).pipe(
Effect.orElseSucceed(() => []),
Effect.flatMap((entries) =>
Effect.forEach(
entries.filter((entry) => !vendored.has(entry.name)),
(entry) =>
entry.type === "directory"
? walk(fs, path.join(directory, entry.name))
: Effect.succeed([path.join(directory, entry.name)]),
),
),
Effect.map((nested) => nested.flat()),
)
}
export function entrypoint(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
return yield* Effect.findFirst(
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
+1 -2
View File
@@ -95,8 +95,7 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (specifie
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () =>
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
try: () => resolveModule(specifier, installed.directory),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
+14 -31
View File
@@ -162,7 +162,7 @@ describe("Npm.cacheKey", () => {
})
describe("Npm.add", () => {
test("resolves cached scoped package specs without reifying", async () => {
test("locates cached scoped package specs without reifying", async () => {
await using tmp = await tmpdir()
const spec = "@fixture/provider@1.0.0"
const directory = path.join(
@@ -185,24 +185,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
})
test("falls back to the original spec when parsing fails", async () => {
await using tmp = await tmpdir()
const spec = "fixture provider"
const directory = path.join(tmp.path, "cache", "npm", Npm.sanitize(spec), "1000", "node_modules", spec)
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, { name: spec, exports: "./index.js" })
await Bun.write(path.join(directory, "index.js"), "export const fixture = true\n")
const entry = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.add(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toBe(directory)
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.name).toBe("@fixture/provider")
})
test("reifies when package cache directory exists without the package installed", async () => {
@@ -224,16 +207,16 @@ describe("Npm.add", () => {
const entries = await Effect.gen(function* () {
const npm = yield* Npm.Service
return {
tui: yield* npm.add(spec, { subpaths: ["tui", ""] }),
fallback: yield* npm.add(spec, { subpaths: ["missing", ""] }),
added: yield* npm.add(spec),
cached: yield* npm.add(spec),
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.tui.entrypoint).toEndWith("/tui.js")
expect(entries.fallback.entrypoint).toEndWith("/index.js")
expect(await fs.stat(path.join(entries.added.directory, "package.json"))).toBeTruthy()
expect(entries.cached).toEqual(entries.added)
})
test.each(["unnamed", "named"])("installs and resolves %s Git packages with dependencies", async (kind) => {
test.each(["unnamed", "named"])("installs and locates %s Git packages with dependencies", async (kind) => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
const spec =
@@ -250,7 +233,7 @@ describe("Npm.add", () => {
}
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href)
expect(entries.added.directory).toEndWith(path.join("node_modules", entries.added.name))
expect(entries.added.version).toBe(fixture.commit)
expect(entries.cached).toEqual(entries.added)
expect(entries.resolved).toEqual(entries.added)
@@ -271,7 +254,7 @@ describe("Npm.add", () => {
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
expect(entry.entrypoint).toEndWith("/index.js")
expect(entry.name).toBe("fixture-subdirectory-plugin")
expect(
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
).toBeTruthy()
@@ -347,16 +330,16 @@ describe("Npm.add", () => {
})
describe("Npm.resolve", () => {
test("resolves a TUI entrypoint only when the package is already cached", async () => {
test("locates a cached package without installing it", async () => {
await using tmp = await tmpdir()
const cache = path.join(tmp.path, "cache")
const spec = "fixture-plugin@1.0.0"
const directory = path.join(cache, "npm", Npm.sanitize(spec), "1000", "node_modules", "fixture-plugin")
const missing = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
return yield* npm.resolve(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(missing.entrypoint).toBeUndefined()
expect(missing.version).toBeUndefined()
await fs.mkdir(directory, { recursive: true })
await writePackage(directory, {
@@ -368,9 +351,9 @@ describe("Npm.resolve", () => {
const resolved = await Effect.gen(function* () {
const npm = yield* Npm.Service
return yield* npm.resolve(spec, { subpaths: ["tui"] })
return yield* npm.resolve(spec)
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(resolved.entrypoint).toEndWith("/tui.js")
expect(resolved.directory).toBe(directory)
expect(resolved.version).toBe("1.0.0")
})
})
@@ -1,146 +0,0 @@
import { describe, expect, setDefaultTimeout } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Duration, Effect, Layer, LayerMap, Schedule } from "effect"
import { Event } from "@opencode-ai/schema/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "@opencode-ai/core/bus"
import { Command } from "@opencode-ai/core/command"
import { Database } from "@opencode-ai/core/database/database"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
// Real Location boot with plugin-directory discovery, so directory plugins are loaded and reloaded from disk.
setDefaultTimeout(15_000)
const watcher = Watcher.testLayer
const instances = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
idleTimeToLive: Duration.infinity,
})
const bindings: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
Watcher.node.replace(watcher),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
Instance.node.replace(
Layer.succeed(Instance.Service, {
provide: (session) => Effect.provide(map.get(session.location)),
}),
),
]
return map
}),
)
const it = testEffect(
Layer.merge(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
watcher,
),
)
// The entrypoint never changes; only the sibling module it imports does.
const index = `import { description } from "./greeting.ts"
export default {
id: "acme",
async setup(ctx) {
await ctx.command.transform((editor) => editor.add({ name: "acme", description, execute: async () => {} }))
},
}`
const greeting = (description: string) => `export const description = ${JSON.stringify(description)}\n`
// Local plugin revisions key on mtime, so give each write a distinct timestamp.
const write = (file: string, content: string, mtime: Date) =>
Effect.promise(async () => {
await Bun.write(file, content)
await fs.utimes(file, mtime, mtime)
})
// Reloads do filesystem work after the publish returns, so poll for the outcome.
const described = (commands: Command.Interface, description: string) =>
commands.get("acme").pipe(
Effect.flatMap((command) =>
command?.description === description ? Effect.succeed(command) : Effect.fail("not reloaded"),
),
Effect.retry({ times: 200, schedule: Schedule.spaced("25 millis") }),
)
describe("directory plugin reload", () => {
it.live("reloads a discovered directory plugin when only a sibling module changes", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const plugin = path.join(directory.path, ".opencode/plugins/acme")
const past = new Date(Date.now() - 60_000)
yield* write(path.join(plugin, "index.ts"), index, past)
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v1"), past)
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
expect(yield* commands.get("acme")).toMatchObject({ description: "Greets v1" })
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v2"), new Date())
yield* bus.publish(Event.Updated, {})
expect(yield* described(commands, "Greets v2")).toMatchObject({ description: "Greets v2" })
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
it.live("watches a configured plugin directory outside config roots as one unit", () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const plugin = path.join(directory.path, "tools/acme")
const past = new Date(Date.now() - 60_000)
yield* write(path.join(plugin, "index.ts"), index, past)
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v1"), past)
// Relative plugin paths resolve against the config file's directory.
yield* Effect.promise(() =>
Bun.write(path.join(directory.path, "opencode.json"), JSON.stringify({ plugins: ["./tools/acme"] })),
)
const locations = yield* LocationServiceMap.Service
const watches = yield* Watcher.Test
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
expect(yield* commands.get("acme")).toMatchObject({ description: "Greets v1" })
expect(yield* watches.subscriptions()).toContainEqual({
path: plugin,
type: "directory",
ignore: [...Watcher.vendored].toSorted(),
})
yield* write(path.join(plugin, "greeting.ts"), greeting("Greets v2"), new Date())
yield* watches.emit({ type: "update", path: path.join(plugin, "greeting.ts") })
expect(yield* described(commands, "Greets v2")).toMatchObject({ description: "Greets v2" })
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
})
+3 -3
View File
@@ -39,10 +39,10 @@ import { emptyMcpLayer } from "../fixture/mcp"
const npmLayer = Layer.succeed(
Npm.Service,
Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
add: (name) => Effect.succeed({ directory: "", name }),
resolve: (name) => Effect.succeed({ directory: "", name }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
update: (name) => Effect.succeed({ directory: "", name }),
which: () => Effect.undefined,
}),
)
+310 -1
View File
@@ -1,7 +1,8 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Exit, Layer, Scope } from "effect"
import { Context, Effect, Exit, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -31,10 +32,245 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
])
const it = testEffect(layer)
const real = testEffect(PluginTestLayer)
const isolated = testEffect(Layer.empty)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
function required<T>(value: T | undefined): T {
if (value === undefined) throw new Error("Expected value")
return value
}
// One complete Location graph behind the production plugin host. Two of these stand in for
// two Locations that share a single models.dev snapshot instance.
const owner = Effect.gen(function* () {
const context = yield* Layer.build(PluginTestLayer)
const host = yield* PluginHost.make(Context.get(context, Plugin.Service)).pipe(Effect.provideContext(context))
return {
context,
host,
bus: Context.get(context, Bus.Service),
catalog: Context.get(context, Catalog.Service),
integration: Context.get(context, Integration.Service),
}
})
// Every nested overlay shape the normalized snapshot can carry, so in-place mutation of any
// existing nested value is observable on the source object.
const richSnapshot = (name = "Acme") => {
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const snapshot = [
{
info: {
id: providerID,
name,
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1", thinking: { type: "adaptive", display: "summarized" } },
headers: { "x-acme": "provider" },
body: { service_tier: "default", tags: ["stable"] },
},
environment: ["ACME_API_KEY", "ACME_HOST"],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
family: Model.Family.make("gpt"),
settings: { baseURL: "https://models.acme.test/v1", reasoning: { effort: "low" } },
headers: { "x-mode": "fast" },
body: { service_tier: "priority", options: { top_k: 1 }, stop: ["<end>"] },
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [
{
id: Model.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
body: { max_tokens: 1024 },
},
],
time: { released: Date.parse("2026-01-01") },
cost: [
{
input: Money.USDPerMillionTokens.make(2.5),
output: Money.USDPerMillionTokens.make(15),
cache: { read: Money.USDPerMillionTokens.make(0.25), write: Money.USDPerMillionTokens.zero },
},
{
tier: { type: "context", size: 200_000 },
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(22.5),
cache: { read: Money.USDPerMillionTokens.make(0.5), write: Money.USDPerMillionTokens.zero },
},
],
status: "active",
enabled: true,
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
return { providerID, modelID, snapshot }
}
describe("ModelsDevPlugin", () => {
isolated.effect("shares one snapshot between Locations while each catalog mutates only its own copies", () =>
Effect.gen(function* () {
const { providerID, modelID, snapshot } = richSnapshot()
const pristine = JSON.stringify(snapshot)
const source = ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void })
const first = yield* owner
const second = yield* owner
for (const each of [first, second])
yield* ModelsDevPlugin.effect(each.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(each.context),
)
// The stored environment method must own its names array; the source array is shared.
let names: readonly string[] | undefined
yield* first.integration.transform((draft) => {
names = draft.method
.list(Integration.ID.make(providerID))
.flatMap((method) => (method.type === "env" ? [method.names] : []))[0]
})
expect(names).toEqual(snapshot[0].environment)
expect(names).not.toBe(snapshot[0].environment)
// Catalog-owned provider records are copies, not the source's nested objects.
const stored = required(yield* first.catalog.provider.get(providerID))
expect(stored.settings).toEqual(snapshot[0].info.settings)
expect(stored.settings).not.toBe(snapshot[0].info.settings)
expect(stored.headers).not.toBe(snapshot[0].info.headers)
expect(stored.body).not.toBe(snapshot[0].info.body)
// A later plugin in the first Location mutates existing nested values in place through the
// production host, the way the Bedrock, Azure, and config provider plugins do.
const scope = yield* Scope.make()
yield* first.host.catalog
.transform((catalog) => {
catalog.provider.update(providerID, (provider) => {
required(provider.settings).baseURL = "https://override.acme.test/v1"
required(provider.settings).thinking.type = "disabled"
required(provider.headers)["x-acme"] = "override"
required(provider.body).service_tier = "priority"
required(provider.body).tags.push("override")
})
catalog.model.update(providerID, modelID, (model) => {
required(model.settings).reasoning.effort = "high"
required(model.headers)["x-mode"] = "slow"
required(model.body).options.top_k = 7
required(model.body).stop.push("<stop>")
const variant = required(model.variants[0])
required(variant.settings).thinking.type = "disabled"
required(variant.body).max_tokens = 4096
model.capabilities.input.push("image")
required(model.cost[0]).cache.read = Money.USDPerMillionTokens.make(9)
required(required(model.cost[1]).tier).size = 1
required(model.cost[1]).input = Money.USDPerMillionTokens.make(42)
model.limit.context = 1
model.time.released = 5
})
})
.pipe(Scope.provide(scope))
const mutatedProvider = required(yield* first.catalog.provider.get(providerID))
const mutated = required(yield* first.catalog.model.get(providerID, modelID))
expect(mutatedProvider.settings).toEqual({
baseURL: "https://override.acme.test/v1",
thinking: { type: "disabled", display: "summarized" },
})
expect(mutatedProvider.headers).toEqual({ "x-acme": "override" })
expect(mutatedProvider.body).toEqual({ service_tier: "priority", tags: ["stable", "override"] })
expect(mutated.settings).toEqual({
baseURL: "https://models.acme.test/v1",
thinking: { type: "disabled", display: "summarized" },
reasoning: { effort: "high" },
})
expect(mutated.headers).toEqual({ "x-acme": "override", "x-mode": "slow" })
expect(mutated.body).toEqual({
service_tier: "priority",
tags: ["stable", "override"],
options: { top_k: 7 },
stop: ["<end>", "<stop>"],
})
expect(mutated.variants).toEqual([
{
id: Model.VariantID.make("low"),
settings: { thinking: { type: "disabled", display: "summarized" }, effort: "low" },
body: { max_tokens: 4096 },
},
])
expect(mutated.capabilities.input).toEqual(["text", "image"])
expect(mutated.cost[0]?.cache.read).toBe(Money.USDPerMillionTokens.make(9))
expect(mutated.cost[1]).toMatchObject({ tier: { type: "context", size: 1 }, input: 42 })
expect(mutated.limit.context).toBe(1)
expect(mutated.time.released).toBe(5)
// The sibling Location and the shared source are untouched.
const sibling = required(yield* second.catalog.model.get(providerID, modelID))
expect(sibling.settings).toEqual({
baseURL: "https://models.acme.test/v1",
thinking: { type: "adaptive", display: "summarized" },
reasoning: { effort: "low" },
})
expect(sibling.headers).toEqual({ "x-acme": "provider", "x-mode": "fast" })
expect(sibling.body).toEqual({
service_tier: "priority",
tags: ["stable"],
options: { top_k: 1 },
stop: ["<end>"],
})
expect(sibling.variants).toEqual(snapshot[0].models[0].variants)
expect(sibling.capabilities.input).toEqual(["text"])
expect(sibling.cost).toEqual(snapshot[0].models[0].cost)
expect(sibling.limit).toEqual(snapshot[0].models[0].limit)
expect(sibling.time).toEqual(snapshot[0].models[0].time)
expect(required(yield* second.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
expect(JSON.stringify(snapshot)).toBe(pristine)
// Removing the mutating transform rebuilds the first catalog from the shared source.
yield* Scope.close(scope, Exit.void)
expect(yield* first.catalog.model.get(providerID, modelID)).toEqual(sibling)
expect(required(yield* first.catalog.provider.get(providerID)).settings).toEqual(snapshot[0].info.settings)
expect(JSON.stringify(snapshot)).toBe(pristine)
}),
)
isolated.effect("replaces its snapshot reference on refresh without touching the sibling Location", () =>
Effect.gen(function* () {
const initial = richSnapshot("Acme")
const refreshed = richSnapshot("Acme Refreshed")
const pristine = JSON.stringify(initial.snapshot)
const current = { snapshot: initial.snapshot }
const source = ModelsDev.Service.of({
get: () => Effect.sync(() => current.snapshot),
refresh: () => Effect.void,
})
const first = yield* owner
const second = yield* owner
for (const each of [first, second])
yield* ModelsDevPlugin.effect(each.host).pipe(
Effect.provideService(ModelsDev.Service, source),
Effect.provideContext(each.context),
)
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme")
current.snapshot = refreshed.snapshot
yield* first.bus.publish(ModelsDev.Event.Refreshed, {})
// Integration and catalog reloads are debounced sequentially.
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
yield* TestClock.adjust("500 millis")
expect(required(yield* first.catalog.provider.get(initial.providerID)).name).toBe("Acme Refreshed")
expect(required(yield* second.catalog.provider.get(initial.providerID)).name).toBe("Acme")
expect(JSON.stringify(initial.snapshot)).toBe(pristine)
expect(JSON.stringify(refreshed.snapshot)).toBe(JSON.stringify(richSnapshot("Acme Refreshed").snapshot))
}),
)
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -92,6 +328,79 @@ describe("ModelsDevPlugin", () => {
}),
)
it.effect("keeps the shared models.dev snapshot pristine while catalog transforms mutate records in place", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("acme")
const modelID = Model.ID.make("gpt-5.4")
const snapshot = [
{
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
headers: { "x-acme": "provider" },
},
environment: ["ACME_API_KEY"],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
settings: { baseURL: "https://models.acme.test/v1" },
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 1_050_000, output: 128_000 },
},
],
},
] satisfies readonly ModelsDev.Snapshot[]
const pristine = JSON.stringify(snapshot)
// The plugin receives the same snapshot instance every Location shares.
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
).pipe(
Effect.provideService(
ModelsDev.Service,
ModelsDev.Service.of({ get: () => Effect.succeed(snapshot), refresh: () => Effect.void }),
),
)
// Later plugins mutate nested provider and model records in place, as the Bedrock provider does.
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
if (provider.settings) provider.settings.baseURL = "https://override.acme.test/v1"
if (provider.headers) provider.headers["x-acme"] = "override"
})
draft.model.update(providerID, modelID, (model) => {
if (model.settings) model.settings.baseURL = "https://override.models.acme.test/v1"
model.variants.push({ id: Model.VariantID.make("configured") })
model.capabilities.input.push("image")
})
})
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(provider?.settings?.baseURL).toBe("https://override.acme.test/v1")
expect(provider?.headers).toEqual({ "x-acme": "override" })
expect(model?.settings?.baseURL).toBe("https://override.models.acme.test/v1")
expect(model?.variants).toEqual([{ id: Model.VariantID.make("configured") }])
expect(model?.capabilities.input).toEqual(["text", "image"])
expect(JSON.stringify(snapshot)).toBe(pristine)
}),
)
it.effect("projects normalized models.dev snapshots into the catalog", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+1 -1
View File
@@ -166,7 +166,7 @@ describe("plan plugin reminders", () => {
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
yield* settle(persisted, 2)
expect(persisted[0]).toContain("You are in Plan mode")
expect(persisted[0]).toContain("optionally create or update plan documents")
expect(persisted[0]).toContain("Do not create or update plan files unless the user explicitly asks you to")
expect(persisted[0]).toContain(planDirectory)
expect(persisted[0]).toContain("Do not modify any other files")
expect(persisted[1]).toContain("NO LONGER in Plan mode")
@@ -4,7 +4,6 @@ import { Cause, Effect, Layer } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Model } from "@opencode-ai/core/model"
@@ -16,16 +15,15 @@ import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
function npmEntrypoint(entrypoint?: string) {
function npmPackage(directory = "") {
return Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint }),
resolve: () => Effect.succeed({ directory: "", entrypoint }),
add: (name) => Effect.succeed({ directory, name }),
resolve: (name) => Effect.succeed({ directory, name }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint }),
update: (name) => Effect.succeed({ directory, name }),
which: () => Effect.undefined,
})
}
@@ -36,13 +34,16 @@ const addPlugin = Effect.fn(function* (npm?: Npm.Interface) {
yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service)))
})
function tempEntrypoint(source: string) {
function tempPackage(source: string) {
return Effect.acquireRelease(
Effect.promise(async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-dynamic-"))
const entrypoint = path.join(directory, "provider.mjs")
await Bun.write(entrypoint, source)
return { directory, entrypoint }
await Bun.write(path.join(directory, "provider.mjs"), source)
await Bun.write(
path.join(directory, "package.json"),
JSON.stringify({ name: "fixture-provider", exports: "./provider.mjs" }),
)
return { directory }
}),
(tmp) => Effect.promise(() => fs.rm(tmp.directory, { recursive: true, force: true })),
)
@@ -86,27 +87,11 @@ describe("DynamicProviderPlugin", () => {
}),
)
it.effect("injects the provider ID as the SDK factory name", () =>
it.live("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin()
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("custom-provider"), Model.ID.make("test-model")),
modelID: Model.ID.make("test-model"),
package: Provider.aisdk(fixtureProvider),
}),
package: fixtureProvider,
options: { name: "custom-provider", marker: "dynamic" },
})
expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" })
}),
)
it.effect("loads npm packages through their resolved import entrypoint", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
const tmp = yield* tempPackage(`export { createFixtureProvider } from ${JSON.stringify(fixtureProvider)}\n`)
yield* addPlugin(npmPackage(tmp.directory))
const result = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("npm-provider"), Model.ID.make("test-model")),
@@ -123,7 +108,7 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* addPlugin(npmEntrypoint())
yield* addPlugin(npmPackage())
const exit = yield* aisdk
.language(
Model.Info.make({
@@ -159,8 +144,8 @@ describe("DynamicProviderPlugin", () => {
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
const tmp = yield* tempPackage("export const notAProviderFactory = true\n")
yield* addPlugin(npmPackage(tmp.directory))
const exit = yield* aisdk
.language(
Model.Info.make({
@@ -14,10 +14,10 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const it = testEffect(PluginTestLayer)
const npm = Npm.Service.of({
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
add: (name) => Effect.succeed({ directory: "", name }),
resolve: (name) => Effect.succeed({ directory: "", name }),
check: () => Effect.succeed(false),
update: () => Effect.succeed({ directory: "", entrypoint: undefined }),
update: (name) => Effect.succeed({ directory: "", name }),
which: () => Effect.undefined,
})
+37
View File
@@ -0,0 +1,37 @@
# Experimental Browser Plugin
`@opencode-ai/plugin-browser` implements the server-side browser tool using only
the public plugin API, public schemas, and Effect. Core registers it as a built-in
plugin; the package does not depend on Core or Server. The shared RPC contract is
`@opencode-ai/schema/browser`; desktop clients do not import Core.
Disable it through normal plugin configuration:
```jsonc
{
"plugins": ["-opencode.browser"],
}
```
The desktop implementation connects with `client.rpc(Browser.Definition)` at the
session's location. Subscribe to server events before calling `attach`; wait for
`server.connected`, then the matching `attached` control event. The `attach` call
stays pending for the attachment lifetime. Abort it when its event stream ends or
the desktop owner closes. Completing the attachment also ends that event consumer.
- `attach` holds one browser attachment per session until cancellation, plugin
unload, session deletion, or session movement.
- `state` reports the current page, or `null` when no page is open.
- `result` completes a command with its request ID and outcome.
- `control` events carry attachment confirmation, commands, and cancellation.
Control events use OpenCode's existing authenticated, server-wide event feed.
Consumers filter by `connectionID`; this identifier is correlation, not private
event delivery. State and results use RPC calls rather than broadcast events.
Per-URL permission checks are deferred to the final permission layer (#46530).
Until that layer lands, browser actions do not enforce URL-specific ask or deny
rules. Attachment ownership, page validation, and cancellation remain enforced.
Browser content is untrusted. Pages use the desktop's network, with no server-side
tunnel. The desktop owns Chromium, page isolation, and native controls.
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/plugin-browser"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
import pkg from "../package.json"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
await $`bun run typecheck`
await $`bun run build`
const original = await Bun.file("package.json").text()
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
try {
await Bun.write(
"package.json",
JSON.stringify(
{
...pkg,
exports: { ".": { import: "./dist/index.js", types: "./dist/index.d.ts" } },
},
null,
2,
) + "\n",
)
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", original)
await rm(tarball, { force: true })
}
+177
View File
@@ -0,0 +1,177 @@
import { Plugin } from "@opencode-ai/plugin/effect"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Session } from "@opencode-ai/schema/session"
import { Tool } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Encoding, Stream } from "effect"
import { Browser } from "@opencode-ai/schema/browser"
type Attachment = {
connectionID: string
state: Browser.State | null
closed: Deferred.Deferred<void>
pending: Map<string, Deferred.Deferred<Browser.Result, Tool.Error>>
}
export default Plugin.define({
id: "opencode.browser",
effect: (ctx) =>
Effect.gen(function* () {
const browsers = new Map<Session.ID, Attachment>()
let active = true
const close = (sessionID: Session.ID) =>
Effect.gen(function* () {
const browser = browsers.get(sessionID)
if (!browser) return
browsers.delete(sessionID)
yield* Deferred.succeed(browser.closed, undefined)
})
yield* Effect.addFinalizer(() => {
active = false
return Effect.forEach(browsers.keys(), close, { discard: true })
})
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
.register(Browser.Definition, {
attach: (input, call) =>
Effect.gen(function* () {
const session = yield* ctx.session
.get({ sessionID: input.sessionID })
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
if (
session.location.directory !== ctx.location.directory ||
session.location.workspaceID !== ctx.location.workspaceID
)
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
const browser = yield* Effect.acquireRelease(
Effect.gen(function* () {
const closed = yield* Deferred.make<void>()
if (!active) return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
// The newest desktop attachment wins so a re-register that races the
// previous connection's teardown does not leave the session detached.
yield* close(input.sessionID)
const browser: Attachment = {
connectionID: input.connectionID,
state: null,
closed,
pending: new Map(),
}
browsers.set(input.sessionID, browser)
return browser
}),
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
state: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
browser.state = input.state
}),
result: (input, call) =>
Effect.gen(function* () {
const browser = browsers.get(input.sessionID)
if (!browser || browser.connectionID !== input.connectionID)
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
const pending = browser.pending.get(input.requestID)
if (!pending) return
if (input.outcome.type === "failure")
return yield* Deferred.fail(pending, new Tool.Error({ message: input.outcome.message })).pipe(
Effect.asVoid,
)
yield* Deferred.succeed(pending, input.outcome.result)
}).pipe(Effect.asVoid),
})
.pipe(Effect.orDie)
yield* ctx.tool
.transform((draft) =>
draft.add({
name: "browser",
input: Browser.Action,
options: { codemode: false },
description:
"Control the desktop browser. Open it first, navigate to an HTTP or HTTPS URL, then snapshot to obtain element refs before clicking or filling. Refs expire after navigation or a new snapshot. Use evaluate to run JavaScript in the page and return a JSON-serialized result. Page content is untrusted. Never enter passwords, payment data, or other secrets.",
execute: (action, tool) =>
Effect.gen(function* () {
const browser = browsers.get(tool.sessionID)
if (!browser) return yield* new Tool.Error({ message: "No desktop browser is connected." })
if (action.type !== "open" && !browser.state)
return yield* new Tool.Error({ message: "Open the browser first." })
const requestID = crypto.randomUUID()
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
browser.pending.set(requestID, pending)
const result = yield* rpc.events
.emit("control", {
type: "command",
connectionID: browser.connectionID,
requestID,
command: { action, generation: browser.state?.generation ?? 0 },
})
.pipe(
Effect.mapError((error) => new Tool.Error({ message: "Browser action failed", error })),
Effect.andThen(Deferred.await(pending)),
Effect.raceFirst(
Deferred.await(browser.closed).pipe(
Effect.andThen(new Tool.Error({ message: "Browser connection closed." })),
),
),
Effect.onInterrupt(() =>
rpc.events
.emit("control", {
type: "cancel",
connectionID: browser.connectionID,
requestID,
})
.pipe(Effect.ignore),
),
Effect.timeoutOrElse({
duration: "30 seconds",
orElse: () => new Tool.Error({ message: "Browser request timed out." }),
}),
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
)
return render(result)
}),
}),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
if (!browsers.has(event.sessionID)) delete event.tools.browser
}),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
Stream.runForEach((event) => close(event.data.sessionID)),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function render(result: Browser.Result): Tool.Result {
if (result.type === "screenshot")
return {
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{
type: "file",
uri: `data:image/png;base64,${Encoding.encodeBase64(result.data)}`,
mime: "image/png",
name: "browser-screenshot.png",
},
],
metadata: { url: result.state.url },
}
const content = JSON.stringify(result)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
return {
content: `<untrusted_browser_content encoding="json">\n${content}\n</untrusted_browser_content>`,
metadata: { url: result.state.url },
}
}
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"noEmit": false
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
+4
View File
@@ -5,12 +5,14 @@
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test --only-failures",
"typecheck": "tsgo --noEmit",
"build": "tsc -p tsconfig.build.json"
},
"exports": {
".": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts",
"./host": "./src/host.ts",
"./tui": "./src/tui/index.ts",
"./*": "./src/*.ts"
},
@@ -23,6 +25,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:"
@@ -53,6 +56,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"solid-js": "catalog:",
"typescript": "catalog:",
+48
View File
@@ -0,0 +1,48 @@
export * as Host from "./host.js"
import path from "node:path"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export interface Target {
readonly directory: string
readonly name?: string
}
export interface Entrypoints {
readonly server?: string
readonly tui?: string
readonly rpc?: string
}
export function resolve(target: Target): Entrypoints {
const entry = (subpaths: readonly string[]) => {
for (const subpath of subpaths) {
const specifier = target.name
? [target.name, subpath].filter(Boolean).join("/")
: path.resolve(target.directory, subpath || "index")
try {
return resolveModule(specifier, target.directory)
} catch (error) {
if (
!(error instanceof Error) ||
!("code" in error) ||
![
"ENOENT",
"ENOTDIR",
"MODULE_NOT_FOUND",
"ERR_MODULE_NOT_FOUND",
"ERR_PACKAGE_PATH_NOT_EXPORTED",
"ERR_UNSUPPORTED_DIR_IMPORT",
].includes(String(error.code))
)
throw error
}
}
return undefined
}
return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
}
export function load(entrypoint: string): Promise<unknown> {
return importModule(entrypoint)
}
+156
View File
@@ -0,0 +1,156 @@
import assert from "node:assert/strict"
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { describe, it } from "node:test"
import { pathToFileURL } from "node:url"
import { Host } from "../src/host.js"
// Every entrypoint throws if evaluated: resolution must never execute plugins.
const source = 'throw new Error("Plugin code must not run during resolution")'
const name = "@fixture/plugin"
async function fixture(files: Record<string, string>, installed = false) {
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
const directory = installed ? path.join(root, "node_modules", name) : root
await Promise.all(
Object.entries(files).map(async ([file, content]) => {
await mkdir(path.dirname(path.join(directory, file)), { recursive: true })
await writeFile(path.join(directory, file), content)
}),
)
return {
target: { directory, ...(installed ? { name } : {}) },
url: (file: string) => pathToFileURL(path.join(directory, file)).href,
[Symbol.asyncDispose]: () => rm(root, { recursive: true, force: true }),
}
}
describe("Host.resolve", () => {
it("resolves conventional entrypoints without package.json", async () => {
await using plugin = await fixture({ "index.ts": source, "tui.tsx": source, "rpc.ts": source })
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("index.ts"),
tui: plugin.url("tui.tsx"),
rpc: plugin.url("rpc.ts"),
})
})
it("resolves a TUI-only directory without an index or package.json", async () => {
await using plugin = await fixture({ "tui.tsx": source })
assert.deepEqual(Host.resolve(plugin.target), {
server: undefined,
tui: plugin.url("tui.tsx"),
rpc: undefined,
})
})
for (const main of [undefined, "lib/backend.js"]) {
it(`resolves packages without exports using ${main ?? "the default index"}`, async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({ name, main }),
[main ?? "index.js"]: source,
"tui.js": source,
"rpc.js": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url(main ?? "index.js"),
tui: plugin.url("tui.js"),
rpc: plugin.url("rpc.js"),
})
})
}
it("resolves local conventional entrypoints with package.json but no exports", async () => {
await using plugin = await fixture({
"package.json": JSON.stringify({ name, type: "module" }),
"index.js": source,
"tui.js": source,
"rpc.js": source,
})
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("index.js"),
tui: plugin.url("tui.js"),
rpc: plugin.url("rpc.js"),
})
})
it("honors exports and prefers the explicit server entrypoint over the root", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({
name,
exports: {
".": "./dist/root.js",
"./server": "./dist/backend.js",
"./tui": "./dist/terminal.js",
"./rpc": "./dist/contract.js",
},
}),
"dist/root.js": source,
"dist/backend.js": source,
"dist/terminal.js": source,
"dist/contract.js": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("dist/backend.js"),
tui: plugin.url("dist/terminal.js"),
rpc: plugin.url("dist/contract.js"),
})
})
it("falls back to the root export and uses import rather than require conditions", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({
name,
exports: {
".": { import: "./dist/server.mjs", require: "./dist/server.cjs" },
"./tui": { import: "./dist/tui.mjs", require: "./dist/tui.cjs" },
},
}),
"dist/server.mjs": source,
"dist/server.cjs": source,
"dist/tui.mjs": source,
"dist/tui.cjs": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: plugin.url("dist/server.mjs"),
tui: plugin.url("dist/tui.mjs"),
rpc: undefined,
})
})
it("supports TUI-only exports without falling back to unexported files", async () => {
await using plugin = await fixture(
{
"package.json": JSON.stringify({ name, exports: { "./tui": "./dist/terminal.js" } }),
"dist/terminal.js": source,
"index.js": source,
"server.js": source,
"rpc.js": source,
},
true,
)
assert.deepEqual(Host.resolve(plugin.target), {
server: undefined,
tui: plugin.url("dist/terminal.js"),
rpc: undefined,
})
})
it("does not return an exported entrypoint whose file is missing", async () => {
await using plugin = await fixture(
{ "package.json": JSON.stringify({ name, exports: { "./tui": "./missing.js" } }) },
true,
)
assert.deepEqual(Host.resolve(plugin.target), { server: undefined, tui: undefined, rpc: undefined })
})
})
+1 -1
View File
@@ -6,7 +6,7 @@
- Preserve the dependency direction: `@opencode-ai/schema <- @opencode-ai/protocol <- @opencode-ai/server`.
- Schema values should be serializable contract definitions, not service implementations or runtime registries.
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.added` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
- A domain may keep a minimal public wire contract here when SDK generation needs it, but do not move the broader runtime model into Schema just because an event is public. `plugin.updated` is the current example: Schema may own the minimum browser-safe event payload, while plugin runtime behavior stays outside Schema.
- The root barrel exports canonical current domain contracts. Specialized event modules, manifests, infrastructure modules, and V1 contracts use direct entrypoints instead of becoming first-class root exports.
## Current Versus V1
+123
View File
@@ -0,0 +1,123 @@
export * as Browser from "./browser.js"
import { Schema } from "effect"
import { Rpc } from "./rpc.js"
import { Session } from "./session.js"
export const Ref = Schema.String.check(Schema.isPattern(/^@?e[1-9][0-9]*$/))
.pipe(Schema.brand("Browser.Ref"))
.annotate({ identifier: "Browser.Ref" })
export type Ref = typeof Ref.Type
export interface State extends Schema.Schema.Type<typeof State> {}
export const State = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: Schema.String.check(Schema.isMaxLength(1_024)),
loading: Schema.Boolean,
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "Browser.State" })
export const Key = Schema.Literals([
"Enter",
"Tab",
"Escape",
"Backspace",
"Delete",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
"Space",
]).annotate({ identifier: "Browser.Key" })
export type Key = typeof Key.Type
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({ identifier: "Browser.Direction" })
export type Direction = typeof Direction.Type
export const Action = Schema.Union([
Schema.Struct({ type: Schema.Literals(["open", "snapshot", "screenshot", "back", "forward", "reload", "stop"]) }),
Schema.Struct({ type: Schema.Literal("navigate"), url: Schema.String.check(Schema.isMaxLength(16_384)) }),
Schema.Struct({ type: Schema.Literal("click"), ref: Ref }),
Schema.Struct({ type: Schema.Literal("fill"), ref: Ref, text: Schema.String.check(Schema.isMaxLength(10_000)) }),
Schema.Struct({ type: Schema.Literal("press"), key: Key }),
Schema.Struct({
type: Schema.Literal("evaluate"),
script: Schema.String.check(Schema.isMaxLength(100_000)).annotate({
description: "JavaScript to evaluate in the page. The result is JSON-serialized.",
}),
}),
Schema.Struct({
type: Schema.Literal("scroll"),
direction: Direction,
pixels: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(2000)),
}),
]).annotate({ identifier: "Browser.Action" })
export type Action = typeof Action.Type
export interface Command extends Schema.Schema.Type<typeof Command> {}
export const Command = Schema.Struct({ action: Action, generation: State.fields.generation }).annotate({
identifier: "Browser.Command",
})
export const Result = Schema.Union([
Schema.Struct({ type: Schema.Literal("state"), state: State }),
Schema.Struct({
type: Schema.Literal("snapshot"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("evaluate"),
state: State,
content: Schema.String.check(Schema.isMaxLength(100_000)),
}),
Schema.Struct({
type: Schema.Literal("screenshot"),
state: State,
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
}),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Result" })
export type Result = typeof Result.Type
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.Literal("success"), result: Result }),
Schema.Struct({ type: Schema.Literal("failure"), message: Schema.String.check(Schema.isMaxLength(1_024)) }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Outcome" })
export type Outcome = typeof Outcome.Type
const attachment = { sessionID: Session.ID, connectionID: Schema.String }
const errors = { unavailable: Schema.Struct({}) }
export const Control = Schema.Union([
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("command"),
connectionID: Schema.String,
requestID: Schema.String,
command: Command,
}),
Schema.Struct({ type: Schema.Literal("cancel"), connectionID: Schema.String, requestID: Schema.String }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Control" })
export type Control = typeof Control.Type
export const Definition = Rpc.define({
id: "experimental.browser",
methods: {
attach: { input: Schema.Struct(attachment), output: Schema.Void, errors },
state: { input: Schema.Struct({ ...attachment, state: Schema.NullOr(State) }), output: Schema.Void, errors },
result: {
input: Schema.Struct({ ...attachment, requestID: Schema.String, outcome: Outcome }),
output: Schema.Void,
errors,
},
},
events: { control: { schema: Control } },
})
+1 -5
View File
@@ -42,12 +42,8 @@ export const Info = Schema.Struct({
state: State,
}).annotate({ identifier: "Plugin.Info" })
const Added = ephemeral({
type: "plugin.added",
schema: { id: ID },
})
const Updated = ephemeral({
type: "plugin.updated",
schema: {},
})
export const Event = { Added, Updated, Definitions: inventory(Added, Updated) }
export const Event = { Updated, Definitions: inventory(Updated) }
+1
View File
@@ -39,6 +39,7 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
+3 -1
View File
@@ -15,6 +15,7 @@ const names = [
"protocol",
"client",
"plugin",
"plugin-browser",
"core",
"simulation",
"server",
@@ -163,12 +164,13 @@ export default {
Bun.write(
join(consumer, "boot.mjs"),
`import { Miniflare } from "miniflare"
import { fileURLToPath } from "node:url"
const miniflare = new Miniflare({
compatibilityDate: "2026-07-15",
compatibilityFlags: ["nodejs_compat"],
modules: true,
scriptPath: new URL("./dist/worker.js", import.meta.url).pathname,
scriptPath: fileURLToPath(new URL("./dist/worker.js", import.meta.url)),
durableObjects: { OPENCODE: { className: "OpenCodeDO", useSQLite: true } },
})
+271
View File
@@ -0,0 +1,271 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import plugin from "@opencode-ai/plugin-browser"
import { Browser } from "@opencode-ai/schema/browser"
import { Agent, Rpc } from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
import { AbsolutePath, Location, OpenCode, SessionMessage } from "@opencode-ai/sdk/effect"
import { Effect, Fiber, Queue, Stream } from "effect"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
const state: Browser.State = {
url: "https://example.com/",
title: "Example",
loading: false,
canGoBack: false,
canGoForward: false,
generation: 7,
}
const fixture = Effect.gen(function* () {
const directory = yield* tmpdirScoped("opencode-browser-")
const config = path.join(directory.path, "config")
yield* Effect.promise(() => mkdir(config))
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const opencode = yield* OpenCode.create({
database: { path: ":memory:" },
config: {
directory: config,
project: false,
content: JSON.stringify({
plugins: ["-opencode.browser"],
}),
},
models: { fetch: false },
fs: { filewatcher: false, fff: false },
})
const captured = Promise.withResolvers<Tool.Info>()
yield* opencode.plugin({ ...plugin, id: "browser-test" })
yield* opencode.plugin({
id: "browser-test-observer",
effect: (ctx) =>
Effect.gen(function* () {
// Inspect the real tool through the public draft, without replacing its executor.
yield* ctx.tool.transform((draft) => {
const tool = draft.get("browser")
if (tool && ctx.location.directory === location.directory) captured.resolve(tool)
})
}).pipe(Effect.orDie),
})
yield* opencode.plugin.list({ location })
const tool = yield* Effect.promise(() => captured.promise)
const session = yield* opencode.sessions.create({ location })
const rpc = opencode.rpc(Browser.Definition)
const events = yield* Queue.unbounded<Rpc.EventPayload<typeof Browser.Definition, "control">>()
yield* rpc.events.subscribe("control").pipe(
Stream.runForEach((event) => Queue.offer(events, event)),
Effect.forkScoped({ startImmediately: true }),
)
// RPC and native subscriptions share one stream; connected is the readiness barrier.
yield* opencode.events.subscribe().pipe(
Stream.filter((event) => event.type === "server.connected"),
Stream.runHead,
Effect.timeout("5 seconds"),
)
const next = Queue.take(events).pipe(Effect.timeout("5 seconds"))
const execute = (action: Browser.Action) =>
tool.execute(action, {
sessionID: session.id,
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.create(),
id: Tool.CallID.make(crypto.randomUUID()),
progress: () => Effect.void,
})
return {
opencode,
location,
rpc,
execute,
next,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach(input, { location }).pipe(Effect.forkScoped)
expect(yield* next).toMatchObject({
type: "rpc.experimental.browser.control",
location,
data: { type: "attached", connectionID },
})
expect(lifetime.pollUnsafe()).toBeUndefined()
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
const pending = yield* execute(action).pipe(Effect.forkScoped)
const event = yield* next.pipe(
Effect.raceFirst(
Fiber.join(pending).pipe(Effect.andThen(Effect.die("Tool completed without a browser command"))),
),
)
expect(event.location).toEqual(location)
if (event.data.type !== "command") throw new Error(`Expected command, received ${event.data.type}`)
expect(event.data.command.action).toEqual(action)
return { ...event.data, pending }
}),
}
})
test(
"attachment ownership, cancellation, and plugin unload release pending browser work",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
const stale = yield* host.attach("stale")
// A newer attachment for the same session replaces the previous one.
const attached = yield* host.attach("first")
yield* Fiber.join(stale.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...stale.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const other = Location.Ref.make({ directory: AbsolutePath.make(path.join(host.location.directory, "other")) })
yield* Effect.promise(() => mkdir(other.directory))
yield* host.opencode.plugin.list({ location: other })
expect(yield* host.rpc.attach(attached.input, { location: other }).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
message: "Session belongs to another location.",
})
expect(
yield* host.rpc.state({ ...attached.input, connectionID: "wrong", state }, options).pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
yield* host.rpc.state({ ...attached.input, state }, options)
yield* host.rpc.state({ ...attached.input, state: null }, options)
expect(yield* host.execute({ type: "snapshot" }).pipe(Effect.flip)).toMatchObject({
message: "Open the browser first.",
})
const cancelled = yield* host.command({ type: "open" })
expect(cancelled.command.generation).toBe(0)
yield* Fiber.interrupt(cancelled.pending)
expect((yield* host.next).data).toEqual({
type: "cancel",
connectionID: attached.input.connectionID,
requestID: cancelled.requestID,
})
// A reply to an interrupted request is harmless while its connection is still attached.
yield* host.rpc.result(
{ ...attached.input, requestID: cancelled.requestID, outcome: { type: "failure", message: "late" } },
options,
)
const closing = yield* host.command({ type: "open" })
yield* Fiber.interrupt(attached.lifetime)
expect(yield* Fiber.join(closing.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
expect(yield* host.rpc.state({ ...attached.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})
const replacement = yield* host.attach("replacement")
const pending = yield* host.command({ type: "open" })
expect(pending.connectionID).toBe("replacement")
expect(pending.command.generation).toBe(0)
expect(
yield* host.rpc
.result(
{
...attached.input,
requestID: pending.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
.pipe(Effect.flip),
).toMatchObject({ type: "unavailable" })
expect(pending.pending.pollUnsafe()).toBeUndefined()
// Replacing the SDK registration unloads the production plugin through its normal lifecycle.
yield* host.opencode.plugin({ id: "browser-test", effect: () => Effect.void })
yield* host.opencode.plugin.list(options)
expect(yield* Fiber.join(pending.pending).pipe(Effect.flip)).toMatchObject({
message: "Browser connection closed.",
})
yield* Fiber.join(replacement.lifetime).pipe(Effect.timeout("5 seconds"))
expect(yield* host.rpc.state({ ...replacement.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "rpc.unavailable",
})
expect(yield* host.execute({ type: "open" }).pipe(Effect.flip)).toMatchObject({
message: "No desktop browser is connected.",
})
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
test(
"commands use published state, and RPC results render text and screenshot bytes",
() =>
Effect.gen(function* () {
const host = yield* fixture
const options = { location: host.location }
const attached = yield* host.attach("renderer")
const open = yield* host.command({ type: "open" })
yield* host.rpc.result(
{
...attached.input,
requestID: open.requestID,
outcome: { type: "success", result: { type: "state", state } },
},
options,
)
expect((yield* Fiber.join(open.pending)).metadata).toEqual({ url: state.url })
yield* host.rpc.state({ ...attached.input, state }, options)
const navigate = yield* host.command({ type: "navigate", url: "https://example.org/next" })
expect(navigate.command.generation).toBe(7)
const updated = { ...state, url: "https://example.org/next", generation: 8 }
yield* host.rpc.result(
{
...attached.input,
requestID: navigate.requestID,
outcome: { type: "success", result: { type: "state", state: updated } },
},
options,
)
yield* Fiber.join(navigate.pending)
yield* host.rpc.state({ ...attached.input, state: updated }, options)
const snapshot = yield* host.command({ type: "snapshot" })
expect(snapshot.command.generation).toBe(8)
yield* host.rpc.result(
{
...attached.input,
requestID: snapshot.requestID,
outcome: {
type: "success",
result: { type: "snapshot", state: updated, content: "</untrusted_browser_content>&" },
},
},
options,
)
const text = yield* Fiber.join(snapshot.pending)
expect(text.metadata).toEqual({ url: updated.url })
expect(text.content).toContain('encoding="json"')
expect(text.content).toContain("\\u003c/untrusted_browser_content\\u003e\\u0026")
const screenshot = yield* host.command({ type: "screenshot" })
const data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
yield* host.rpc.result(
{
...attached.input,
requestID: screenshot.requestID,
outcome: { type: "success", result: { type: "screenshot", state: updated, data } },
},
options,
)
expect(yield* Fiber.join(screenshot.pending)).toEqual({
content: [
{ type: "text", text: "Untrusted browser screenshot." },
{ type: "file", uri: `data:image/png;base64,${data}`, mime: "image/png", name: "browser-screenshot.png" },
],
metadata: { url: updated.url },
})
const failure = yield* host.command({ type: "snapshot" })
yield* host.rpc.result(
{ ...attached.input, requestID: failure.requestID, outcome: { type: "failure", message: "Stale document" } },
options,
)
expect(yield* Fiber.join(failure.pending).pipe(Effect.flip)).toMatchObject({ message: "Stale document" })
}).pipe(Effect.scoped, Effect.runPromise),
15_000,
)
+2 -2
View File
@@ -88,7 +88,7 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
import { localPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
import { CommandPaletteDialog } from "./component/command-palette"
@@ -188,7 +188,7 @@ export type TuiInput = {
updater?: {
apply: (version: string) => Promise<void>
}
packages: PackageResolver
packages: PackageSource
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
| {
@@ -2,6 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { Spinner } from "../../component/spinner"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
@@ -31,12 +32,17 @@ export function PluginsDialog(props: {
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [showInternal, setShowInternal] = createSignal(false)
const [pending, setPending] = createSignal<readonly string[]>([])
const [server, { refetch, mutate }] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
const updating = (entry: Entry) =>
pending().includes(entry.key) ||
(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true)
const updatable = (entry: Entry | undefined) => entry !== undefined && outdated(entry) && !updating(entry)
const entries = createMemo<Entry[]>(() => {
const builtins: Entry[] = props.plugins
.registered()
@@ -86,15 +92,16 @@ export function PluginsDialog(props: {
value: entry.key,
category: entry.runtime === "tui" ? "TUI" : "Server",
searchText: entry.runtime === "tui" ? entry.target : source(entry.plugin, props.context),
footer: footer(entry),
footer: updating(entry) ? "updating" : footer(entry),
footerColor:
status(entry) === "failed"
? props.context.theme.text.feedback.error.default
: outdated(entry)
? props.context.theme.text.feedback.info.default
: props.context.theme.text.subdued,
gutter:
status(entry) === "failed"
gutter: updating(entry)
? (color) => <Spinner color={color} />
: status(entry) === "failed"
? () => <text fg={props.context.theme.text.feedback.error.default}>x</text>
: undefined,
}),
@@ -131,20 +138,22 @@ export function PluginsDialog(props: {
}
const update = (entry: Entry | undefined) => {
if (entry?.runtime !== "server" || entry.plugin.source.type !== "package" || !updatable(entry)) return
const location = props.context.location ?? props.context.data.location.default()
setPending((keys) => [...keys, entry.key])
props.context.client.plugin
.update({
location: props.context.location ?? props.context.data.location.default(),
location,
targets: [entry.plugin.source.target],
})
.then(() =>
props.context.ui.toast.show({ variant: "success", message: `Updated plugin ${label(entry, props.context)}` }),
)
.then(() => props.context.client.plugin.awaitActivation({ location }))
.then(() => refetch())
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
})
})
.finally(() => setPending((keys) => keys.filter((key) => key !== entry.key)))
}
// The server only re-checks package sources on startup and then caches the
// result for a day, so a merge pushed after launch stays invisible until the
@@ -287,21 +296,13 @@ function outdated(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
}
function updating(entry: Entry) {
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true
}
function updatable(entry: Entry | undefined) {
return entry !== undefined && outdated(entry) && !updating(entry)
}
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),
...(updating(entry) ? ["updating"] : outdated(entry) ? ["update available"] : []),
...(outdated(entry) ? ["update available"] : []),
]
return details.length ? details.join(", ") : undefined
}
+17 -25
View File
@@ -15,9 +15,10 @@ import {
} from "solid-js"
import path from "path"
import { readFile, stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import { fileURLToPath } from "url"
import type { Page } from "@opencode-ai/plugin/tui/context"
import { Hash } from "@opencode-ai/util/hash"
import { Host } from "@opencode-ai/plugin/host"
import { resolveSlots, type Claim } from "./structure"
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
import { isDeepEqual } from "remeda"
@@ -30,11 +31,11 @@ import { errorMessage } from "../util/error"
import { builtins } from "./builtins"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverTuiPlugins, freshSpecifier, localSource, tuiEntrypoint } from "./discovery"
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
import { isMissingPath } from "../util/config-directories"
export interface PackageResolver {
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
export interface PackageSource {
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
}
type State =
@@ -93,7 +94,7 @@ export function combineMarkdownRenderers(
return createMarkdownCodeBlockRenderer(renderers)
}
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
const host = usePluginHost()
const config = useConfig()
const lifecycle = useTuiLifecycle()
@@ -265,19 +266,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const reconcile = async () => {
await Promise.all(props.directories.map(watcher.wait))
const entries = [
...(await discoverTuiPlugins(props.directories)).map((entry) => ({
...(await discoverPluginTargets(props.directories)).map((entry) => ({
entry,
install: true,
server: false,
discovered: true,
optional: true,
})),
...serverPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
server: true,
discovered: false,
optional: true,
})),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false, discovered: false })),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })),
]
// Resolve: fold entries into one desired generation. A source that fails
@@ -304,7 +303,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
const local = localSource(target, directory)
if (
local &&
!source.discovered &&
(await stat(local).then(
(info) => info.isFile(),
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
@@ -324,7 +322,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
}),
)
if (resolved.status === "unsupported") {
if (source.server) continue
if (source.optional) continue
failures.push({ target, status: "unsupported" })
continue
}
@@ -598,7 +596,7 @@ async function resolvePlugin(
local: URL | undefined,
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
packages: PackageSource,
install: boolean,
sourceGeneration: (entrypoint: string) => Promise<number>,
) {
@@ -606,7 +604,8 @@ async function resolvePlugin(
// version needs no re-resolution (which could otherwise hit npm).
if (!local && previous && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
const target = local ? { directory: fileURLToPath(local) } : await packages.prepare(spec, install)
const entrypoint = Host.resolve(target).tui
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
@@ -615,7 +614,7 @@ async function resolvePlugin(
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
const mod = await Host.load(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
// In-place saves can change the file between hashing and import. Retry
@@ -625,7 +624,8 @@ async function resolvePlugin(
continue
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
if (typeof mod !== "object" || mod === null || !("default" in mod) || !isPlugin(mod.default))
throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
}
@@ -671,14 +671,6 @@ function snapshotOptions(options: Registration["options"]) {
return options ? structuredClone(unwrap(options)) : undefined
}
async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
if (!info.isDirectory()) return
const entrypoint = await tuiEntrypoint(fileURLToPath(url))
return entrypoint ? pathToFileURL(entrypoint).href : undefined
}
function isPlugin(value: unknown): value is Plugin.Definition {
return (
typeof value === "object" &&
+2 -12
View File
@@ -3,8 +3,6 @@ import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
const extensions = [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]
export async function localPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
@@ -21,7 +19,7 @@ export async function localPluginDirectories(cwd: string, configDirectory: strin
return directories.filter((_, index) => exists[index]).map((directory) => path.join(directory, "plugins"))
}
export async function discoverTuiPlugins(directories: string[]) {
export async function discoverPluginTargets(directories: string[]) {
return (
await Promise.all(
directories.map(async (directory) => {
@@ -43,7 +41,7 @@ export async function discoverTuiPlugins(directories: string[]) {
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
))
if (!isDirectory) return undefined
return tuiEntrypoint(plugin)
return plugin
}),
)
).filter((entry): entry is string => entry !== undefined)
@@ -52,14 +50,6 @@ export async function discoverTuiPlugins(directories: string[]) {
).flat()
}
export async function tuiEntrypoint(directory: string) {
const files = await readdir(directory, { withFileTypes: true })
const names = new Set(files.filter((file) => file.isFile() || file.isSymbolicLink()).map((file) => file.name))
if (!extensions.some((extension) => names.has("index" + extension))) return undefined
const tui = extensions.find((extension) => names.has("tui" + extension))
return tui ? path.join(directory, "tui" + tui) : undefined
}
export function localSource(spec: string, directory: string) {
if (spec.startsWith("file://")) return new URL(spec)
if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec))
+6 -6
View File
@@ -239,7 +239,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
@@ -315,7 +315,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -389,7 +389,7 @@ test("session title generated while an untitled session is loading remains visib
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
@@ -775,7 +775,7 @@ test("session startup prompt is submitted exactly once", async () => {
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
log: () => {},
@@ -1309,7 +1309,7 @@ test.each(["manual", "select"] as const)(
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
log: () => {},
@@ -1395,7 +1395,7 @@ async function createAppFixture(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
+2 -2
View File
@@ -59,7 +59,7 @@ test.each([70, 120])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
@@ -184,7 +184,7 @@ test.each(["first", "second"])(
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
+1 -1
View File
@@ -103,7 +103,7 @@ test.each([
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "ses_test" },
log: () => {},
@@ -79,7 +79,7 @@ test.each([80, 120, 180])("session wheel scrolling preserves pane focus at width
get: async () => ({ animations: false, session: { terminal: true }, tabs: { enabled: false } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
+1 -1
View File
@@ -41,7 +41,7 @@ test("stats shows only this year and returns after errors or success", async ()
get: async () => ({ animations: false, tabs: { enabled: false } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: {},
log: () => {},
+20
View File
@@ -16,6 +16,26 @@ The `/admin*` route must be protected by a Cloudflare Access self-hosted applica
The Worker has `workers_dev` and preview URLs disabled so the custom hostname is its only public entry point.
## Request logging
Every request reaching the Worker emits an unsampled event at request start to the shared production
Cloudflare lake stream through the `EVENTS` Pipelines binding. Events use
`source: "update"`, `type: "request"`, an ISO `timestamp`, and a `payload` containing
the method, path, `useragent`, `ip` (from Cloudflare's `CF-Connecting-IP` header),
country, and Cloudflare colo. Query strings, request bodies, cookies, and authorization
headers are not included. Response status and duration are not recorded.
Delivery runs in `waitUntil` without delaying the response. Delivery failures are
logged but do not fail requests or retry; this is not lossless audit logging.
Requests blocked before reaching the Worker are not recorded.
The stream ID in `wrangler.jsonc` comes from the `lake.stream` output of the
`anomalyco/platform/production` Pulumi stack. Update the binding if that stream is
replaced. The stream is shared across release channels because the update service
has a single public deployment.
## Publishing
GitHub Actions publishes artifacts through `POST /api/publish` using a short-lived OIDC token with audience `https://update.opencode.ai`. The Worker accepts only tokens signed by GitHub for repository ID `975734319`, owner ID `66570915`, and `.github/workflows/publish.yml` on configured publishing refs.
Apply migrations and deploy from this directory:
-110
View File
@@ -1,110 +0,0 @@
import { describe, expect, test } from "bun:test"
import worker, { channelsForRef, resolveChannel, validGitHubClaims } from "./index"
const claims = {
repository: "anomalyco/opencode",
repository_id: "975734319",
repository_owner_id: "66570915",
workflow_ref: "anomalyco/opencode/.github/workflows/publish.yml@refs/heads/dev",
ref: "refs/heads/dev",
sha: "abc123",
run_id: "123",
run_attempt: "1",
actor: "opencode-agent",
}
describe("GitHub publish authorization", () => {
test("allows the publish workflow from the repository", () => {
expect(validGitHubClaims(claims)).toBe(true)
expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"])
})
test("maps V2 development to the dev channel", () => {
expect(channelsForRef("refs/heads/v2")).toEqual(["dev"])
})
test("rejects another repository or workflow", () => {
expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false)
expect(
validGitHubClaims({ ...claims, workflow_ref: "anomalyco/opencode/.github/workflows/other.yml@refs/heads/dev" }),
).toBe(false)
})
test("rejects unconfigured refs", () => {
const ref = "refs/heads/untrusted"
expect(
validGitHubClaims({ ...claims, ref, workflow_ref: `anomalyco/opencode/.github/workflows/publish.yml@${ref}` }),
).toBe(false)
})
})
test("routes the retired next channel to beta", () => {
expect(resolveChannel("next")).toBe("beta")
expect(resolveChannel("dev")).toBe("dev")
})
const artifact = {
channel: "beta",
name: "opencode",
distribution: "darwin-arm64",
version: "1.0.0",
metadata: "{}",
active: 1,
time_created: 1,
time_updated: 2,
}
test.each([
["/api/next", ["beta"], { channel: "beta", artifacts: [{ ...artifact, metadata: {}, active: true }] }],
[
"/api/next/opencode",
["beta", "opencode"],
{ channel: "beta", name: "opencode", artifacts: [{ ...artifact, metadata: {}, active: true }] },
],
[
"/api/next/opencode/darwin-arm64",
["beta", "opencode", "darwin-arm64"],
{ ...artifact, metadata: {}, active: true },
],
])("routes GET %s", async (path, expectedBindings, expectedBody) => {
const bindings: unknown[][] = []
const statement = {
bind(...values: unknown[]) {
bindings.push(values)
return statement
},
async all() {
return { results: [artifact] }
},
async first() {
return artifact
},
}
const db = {
prepare() {
return statement
},
} as unknown as D1Database
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
expect(response.status).toBe(200)
expect(await response.text()).toBe(JSON.stringify(expectedBody))
expect(bindings).toEqual([expectedBindings])
})
test.each(["/api", "/v1/dev", "/api/dev/opencode/darwin-arm64/extra", "/api/dev/opencode/darwin$arm64"])(
"returns 404 for GET %s",
async (path) => {
const db = {
prepare() {
throw new Error("Invalid routes must not query the database")
},
} as unknown as D1Database
const response = await worker.fetch(new Request(`https://update.opencode.ai${path}`), { DB: db })
expect(response.status).toBe(404)
expect(await response.text()).toBe("Not found")
},
)
+20 -1
View File
@@ -1,7 +1,9 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"
import type { Pipeline } from "cloudflare:pipelines"
interface Env {
DB: D1Database
EVENTS: Pipeline
}
type ArtifactRow = {
@@ -31,8 +33,25 @@ const audience = "https://update.opencode.ai"
const githubKeys = createRemoteJWKSet(new URL("https://token.actions.githubusercontent.com/.well-known/jwks"))
export default {
async fetch(request: Request, env: Env): Promise<Response> {
async fetch(request: Request, env: Env, ctx: Pick<ExecutionContext, "waitUntil">): Promise<Response> {
const url = new URL(request.url)
ctx.waitUntil(
env.EVENTS.send([
{
source: "update",
type: "request",
timestamp: new Date().toISOString(),
payload: {
method: request.method,
path: url.pathname,
useragent: request.headers.get("user-agent"),
ip: request.headers.get("cf-connecting-ip"),
cf_country: request.cf?.country,
cf_colo: request.cf?.colo,
},
},
]).catch(() => console.error("Failed to send update request event to the data lake")),
)
if (url.pathname === "/") return json({ service: "opencode-updates" })
if (url.pathname === "/admin" && request.method === "GET") return admin(request, env)
+7
View File
@@ -5,6 +5,13 @@
"compatibility_date": "2026-07-21",
"workers_dev": false,
"preview_urls": false,
"pipelines": [
{
"binding": "EVENTS",
// Shared lake stream from anomalyco/platform/production.
"stream": "251a89241c3a461c9007f6b6f345ed8b",
},
],
"routes": [
{
"pattern": "update.opencode.ai",
+1 -2
View File
@@ -54,8 +54,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:"
"pacote": "21.5.1"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
+33 -78
View File
@@ -11,7 +11,6 @@ import { filesystem } from "./effect/app-node-platform.js"
import { LayerNode } from "./effect/layer-node.js"
import { makeRuntime } from "./effect/runtime.js"
import { NpmConfig } from "./npm-config.js"
import { resolveModule } from "#runtime-import"
export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
@@ -19,24 +18,18 @@ export class InstallFailedError extends Schema.TaggedError<InstallFailedError>()
cause: Schema.optional(Schema.Defect()),
}) {}
export interface EntryPoint {
export interface Package {
readonly directory: string
readonly entrypoint?: string
readonly name: string
readonly version?: string
readonly revision?: string
}
export interface Interface {
readonly add: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
readonly add: (pkg: string) => Effect.Effect<Package, InstallFailedError | EffectFlock.LockError>
readonly resolve: (pkg: string) => Effect.Effect<Package>
readonly check: (pkg: string) => Effect.Effect<boolean, InstallFailedError>
readonly update: (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly update: (pkg: string) => Effect.Effect<Package, InstallFailedError | EffectFlock.LockError>
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
}
@@ -114,22 +107,6 @@ function gitSlug(pkg: string) {
)
}
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
const entrypoint = subpaths
.map((subpath) => {
try {
return resolveModule([name, subpath].filter(Boolean).join("/"), dir)
} catch {
return undefined
}
})
.find((entrypoint) => entrypoint !== undefined)
return {
directory: dir,
entrypoint,
}
}
interface ArboristNode {
name: string
path: string
@@ -215,13 +192,7 @@ const layer = Layer.effect(
if (revision) return revision
}
})
const entry = Effect.fnUntraced(function* (
root: string,
name: string,
dir: string,
target: Target | undefined,
subpaths?: readonly string[],
) {
const metadata = Effect.fnUntraced(function* (root: string, name: string, dir: string, target: Target | undefined) {
const manifest = yield* afs
.readJson(path.join(dir, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(PackageJson)), Effect.option)
@@ -229,7 +200,8 @@ const layer = Layer.effect(
const revision = target ? (yield* installedRevision(root, name, target)) ?? manifestVersion : undefined
const version = target?.type === "git" ? revision : manifestVersion
return {
...resolveEntryPoint(name, dir, subpaths),
directory: dir,
name,
...(version ? { version } : {}),
...(revision ? { revision } : {}),
}
@@ -274,14 +246,13 @@ const layer = Layer.effect(
pkg: string,
target: Target | undefined,
dir: string,
subpaths: readonly string[] | undefined,
update: boolean,
) {
yield* flock.acquire(`npm-install:${dir}`)
const active = yield* current(dir)
const name = yield* installedName(pkg, active ?? dir, target)
if (active && !update && (yield* afs.existsSafe(path.join(active, "node_modules", name)))) {
return yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
return yield* metadata(active, name, path.join(active, "node_modules", name), target)
}
yield* mkdir(dir)
@@ -297,26 +268,25 @@ const layer = Layer.effect(
const tree = yield* reify({ dir: staging, config: dir, add: [pkg], update })
const installed = tree.edgesOut.values().next().value?.to
const installedNameValue = installed?.name ?? (yield* installedName(pkg, staging, target))
const result = yield* entry(
const result = yield* metadata(
staging,
installedNameValue,
installed?.path ?? path.join(staging, "node_modules", installedNameValue),
target,
// Resolve installed entrypoints after rename so Bun cannot hold the staging directory open on Windows.
installed ? [] : subpaths,
)
if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging })
if (!installed && !(yield* afs.isDir(result.directory)))
return yield* new InstallFailedError({ add: [pkg], dir: staging })
const links =
process.platform === "win32"
? Array.from(tree.inventory.values()).filter(
(node) => node.isLink && FSUtil.contains(staging, node.path) && FSUtil.contains(staging, node.realpath),
)
: []
return { name: installedNameValue, result, links }
return { result, links }
}).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
if (active) {
const activeEntry = yield* entry(active, name, path.join(active, "node_modules", name), target, subpaths)
const activeEntry = yield* metadata(active, name, path.join(active, "node_modules", name), target)
if (activeEntry.revision && activeEntry.revision === staged.result.revision) {
yield* remove(staging, dir)
return activeEntry
@@ -343,13 +313,7 @@ const layer = Layer.effect(
).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore)))
}
yield* rename(staging, generation, dir)
return yield* entry(
generation,
staged.name,
path.join(generation, "node_modules", staged.name),
target,
subpaths,
)
return { ...staged.result, directory: path.join(generation, "node_modules", staged.result.name) }
})
const collect = Effect.fnUntraced(function* (dir: string) {
@@ -373,26 +337,20 @@ const layer = Layer.effect(
)
})
const add = Effect.fn("Npm.add")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
return yield* install(pkg, target, dir, options?.subpaths, false)
return yield* install(pkg, target, dir, false)
}, Effect.scoped)
const resolve = Effect.fn("Npm.resolve")(function* (
pkg: string,
options?: { readonly subpaths?: readonly string[] },
) {
const resolve = Effect.fn("Npm.resolve")(function* (pkg: string) {
const target = yield* Effect.promise(() => parse(pkg))
const root = directory(pkg, target)
const generation = yield* current(root)
const name = yield* installedName(pkg, generation ?? root, target)
const dir = path.join(generation ?? root, "node_modules", name)
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
return yield* entry(generation ?? root, name, dir, target, options?.subpaths)
if (!(yield* afs.existsSafe(dir))) return { directory: dir, name }
return yield* metadata(generation ?? root, name, dir, target)
})
const check = Effect.fn("Npm.check")(function* (pkg: string) {
@@ -421,22 +379,19 @@ const layer = Layer.effect(
return installed !== available
})
const update = Effect.fn("Npm.update")(
function* (pkg: string, options?: { readonly subpaths?: readonly string[] }) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
if (!target)
return yield* new InstallFailedError({
dir,
cause: new Error("Package updates only support registry and Git package specs"),
})
if (!target.mutable) return yield* add(pkg, options)
const installed = yield* install(pkg, target, dir, options?.subpaths, true)
yield* collect(dir)
return installed
},
Effect.scoped,
)
const update = Effect.fn("Npm.update")(function* (pkg: string) {
const target = yield* Effect.promise(() => parse(pkg))
const dir = directory(pkg, target)
if (!target)
return yield* new InstallFailedError({
dir,
cause: new Error("Package updates only support registry and Git package specs"),
})
if (!target.mutable) return yield* add(pkg)
const installed = yield* install(pkg, target, dir, true)
yield* collect(dir)
return installed
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const target = yield* Effect.promise(() => parse(pkg))
+3 -27
View File
@@ -1,34 +1,10 @@
import path from "node:path"
// A query on a plain-path import makes Bun evaluate a fresh copy of that file,
// but the file's relative imports still resolve to their cached originals. Carry
// the importer's query down so a re-imported entrypoint re-evaluates its whole
// local module graph, not just its own file. Bare specifiers are untouched.
Bun.plugin({
name: "opencode-import-query",
setup(build) {
build.onResolve({ filter: /^\.\.?\// }, (args) => {
const index = args.importer.indexOf("?")
if (index === -1) return undefined
const resolved = resolveRelative(args.path, path.dirname(args.importer.slice(0, index)))
return resolved ? { path: `${resolved}${args.importer.slice(index)}` } : undefined
})
},
})
// Fall through to Bun's own resolution (and its error message) when the import is unresolvable.
function resolveRelative(specifier: string, directory: string) {
try {
return Bun.resolveSync(specifier, directory)
} catch {
return undefined
}
}
import { pathToFileURL } from "node:url"
export function importModule(specifier: string) {
return import(specifier) as Promise<unknown>
}
export function resolveModule(specifier: string, directory: string) {
return import.meta.resolve(specifier, directory)
const resolved = Bun.resolveSync(specifier, directory)
return resolved.startsWith("node:") ? resolved : pathToFileURL(resolved).href
}
+42 -33
View File
@@ -1,33 +1,8 @@
import { Script, constants } from "node:vm"
import { createRequire, registerHooks } from "node:module"
import { registerHooks } from "node:module"
import { statSync } from "node:fs"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { resolve, type Package } from "resolve.exports"
let conditions: readonly string[] = []
const conditionHooks = registerHooks({
resolve(specifier, context, nextResolve) {
conditions = context.conditions
return nextResolve(specifier, context)
},
})
await new Script('import("node:module")', {
importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
}).runInThisContext()
conditionHooks.deregister()
// A query on a file: import makes Node evaluate a fresh copy of that file, but
// the file's relative imports still resolve to their cached originals. Carry the
// importer's query down so a re-imported entrypoint re-evaluates its whole local
// module graph, not just its own file. Bare specifiers are untouched.
registerHooks({
resolve(specifier, context, nextResolve) {
const result = nextResolve(specifier, context)
if (!context.parentURL || !/^\.\.?\//.test(specifier) || !result.url.startsWith("file:")) return result
const search = new URL(context.parentURL).search
return search ? { ...result, url: `${result.url}${search}` } : result
},
})
export async function importModule(specifier: string) {
const imported = (await new Script(`import(${JSON.stringify(specifier)})`, {
@@ -43,10 +18,44 @@ export async function importModule(specifier: string) {
}
export function resolveModule(specifier: string, directory: string) {
const pkg = createRequire(import.meta.url)(path.join(directory, "package.json")) as Package
const target = resolve(pkg, specifier, { conditions, unsafe: true })?.[0]
if (target) return pathToFileURL(path.resolve(directory, target)).href
const legacyTarget =
specifier === pkg.name ? directory : path.resolve(directory, specifier.slice(pkg.name.length + 1))
return pathToFileURL(createRequire(path.join(directory, "package.json")).resolve(legacyTarget)).href
// Node only accepts import.meta.resolve's parent URL behind an experimental
// flag. Scope this synchronous resolution to the caller's package directory
// through the supported resolver hook instead.
const hook = registerHooks({
resolve(specifier, context, nextResolve) {
return nextResolve(specifier, { ...context, parentURL: pathToFileURL(path.join(directory, "package.json")).href })
},
})
try {
const resolve = (target: string) => {
const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target)
if (resolved.startsWith("file:")) statSync(new URL(resolved))
return resolved
}
try {
return resolve(specifier)
} catch (error) {
if (path.extname(specifier) || !missing(error)) throw error
// Node does not infer extensions for local files or legacy package
// subpaths. Resolve each candidate natively so package exports still apply.
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]) {
try {
return resolve(specifier + extension)
} catch (cause) {
if (!missing(cause)) throw cause
}
}
throw error
}
} finally {
hook.deregister()
}
}
function missing(error: unknown) {
return (
error instanceof Error &&
"code" in error &&
["ENOENT", "ENOTDIR", "ERR_MODULE_NOT_FOUND"].includes(String(error.code))
)
}
+3
View File
@@ -62,6 +62,9 @@ await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin-browser ===\n")
await $`bun ./packages/plugin-browser/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env bun
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
const packages = ["@opencode-ai/simulation", "@opencode-ai/server"]
const publish = Bun.argv.includes("--publish")
if (!publish) {
console.log("Package names to reserve:")
for (const name of packages) console.log(`- ${name}`)
console.log("\nRun `bun run reserve-packages --publish` to log in and publish reservation placeholders.")
process.exit(0)
}
if (run(["npm", "whoami"]) !== 0) {
if (run(["npm", "login"]) !== 0) throw new Error("npm login failed")
if (run(["npm", "whoami"]) !== 0) throw new Error("npm authentication failed")
}
const directory = await mkdtemp(path.join(tmpdir(), "opencode-package-reservations-"))
try {
for (const name of packages) {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`)
if (response.ok) {
console.log(`Skipping ${name}: already exists on npm`)
continue
}
if (response.status !== 404) throw new Error(`Failed to check ${name}: npm returned ${response.status}`)
const target = path.join(directory, name.slice("@opencode-ai/".length))
await mkdir(target, { recursive: true })
await Bun.write(
path.join(target, "package.json"),
JSON.stringify(
{
name,
version: "0.0.0-reserved",
description: "Reserved for OpenCode",
license: "MIT",
repository: {
type: "git",
url: "git+https://github.com/anomalyco/opencode.git",
},
publishConfig: {
access: "public",
tag: "reserved",
},
},
null,
2,
) + "\n",
)
await Bun.write(path.join(target, "README.md"), `# ${name}\n\nReserved for OpenCode.\n`)
console.log(`Reserving ${name}`)
if (run(["npm", "publish", "--access", "public", "--tag", "reserved"], target) !== 0) {
throw new Error(`Failed to reserve ${name}`)
}
}
} finally {
await rm(directory, { recursive: true, force: true })
}
function run(command: string[], cwd?: string) {
return Bun.spawnSync(command, {
cwd,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
}).exitCode
}