Compare commits

...
14 changed files with 276 additions and 50 deletions
+16 -16
View File
@@ -592,8 +592,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.9",
"@opentui/solid": ">=0.5.9",
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
@@ -1078,9 +1078,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@pierre/diffs": "1.2.10",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.71.0",
@@ -2221,27 +2221,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@opentui/core": ["@opentui/core@0.5.9", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.9", "@opentui/core-darwin-x64": "0.5.9", "@opentui/core-linux-arm64": "0.5.9", "@opentui/core-linux-arm64-musl": "0.5.9", "@opentui/core-linux-x64": "0.5.9", "@opentui/core-linux-x64-musl": "0.5.9", "@opentui/core-win32-arm64": "0.5.9", "@opentui/core-win32-x64": "0.5.9" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-d0EWYyp6djitu1N1R0o75NrLl4TxY3oJEmRNrX9vSKKC5/jriGQdSV6lJmwiB77O0cxtBnPzWztAV79vA1J2fA=="],
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YcpWGD8GwoO3UYYw0kLDI4qof3ElSwWW5M3fNG+Kw87BSjCa5frZqak5xebdZ/XU5Xsk+BjgbBsxbzo9yrIjZQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-xgdZwgcwlDCqi0WPCS1d0GVSd+oKDFX+UVE1ZPei8u8W3pirXGSExxVHNku7QbEYCRvpCeMtoKIa6xN9cVTsoQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-vHBiJp69wMaL+fRMDpnXCCIpPyQRPZEWCWBatnblUUGIWUseqeQlaPow6ljHSbgAd/GjygAf4tFCyTqNT2ezZA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-A7GW4NCTInj2vy3X2gVO2Sm8jllgdLxwPdOklPqyl+xqivQtyG9qwS5YXoCk9fBJt10ORhYKETEjYEnIE5xvMQ=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-rdw7QVopVHwj6fMrJc7hPGVaYg2V5NBlW3MUfkrQBlUlo/87OC4aYDAh5oRIL1CIFD9LjDdh7hwmUNbn/K9Pcw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-J4wQs1OMPZ4hR93Op1C/BLFmIta2mUJm4M7djevlgcWcal4RSNO4V8QvUXfogWGci+ADZqEmX2osQQDY5NVJpw=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-OnmEsGLWwsHrm6sTsWSSesu4LGjSV1JNWqCsEItbuitWaqH6rXdSahSc5jMFsS57bZxUyaRz01yAqiZzUSXnVQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/CnAIfKL7+ZeGLyZoXV5zS71Nd8Zn97RUir2DAIY05MJozmfg5s7XOAdUNYuK1cg5mbwlAuqeAsfbuWUAEe15g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
"@opentui/keymap": ["@opentui/keymap@0.5.9", "", { "dependencies": { "@opentui/core": "0.5.9" }, "peerDependencies": { "@opentui/react": "0.5.9", "@opentui/solid": "0.5.9", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-ZcRNeuCDv+LJ89BS5xBw90e0S1Etn6j41jflg6LZlWj4bnqjCU55dq0+zERUdkkC4rqD4SikM8q7V3ZfkwzyyQ=="],
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
"@opentui/solid": ["@opentui/solid@0.5.9", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.9", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-zGSP/ia9ww+TTMvQMZjJW2+h7fg9YkVRIM723EgvP+v7ZBZ/0vMzPNh7FJ0jET3ysw0h5hsbvcYISs4sUcf+UA=="],
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Xjo7b+y3TG0fIoaiQ86tV98MGofO52aMGMBMlvDCNs0=",
"aarch64-linux": "sha256-9F6LHNOfzkyakz3GScvuzIsy0vCaQRuTZRdFSYHLanY=",
"aarch64-darwin": "sha256-U7TihrGM9l2vSU7Z6K2KinFvKQ5fBJyK4zXoDyZFChI=",
"x86_64-darwin": "sha256-JgenSPWgdxPjiHQiblhIM/+AL3oxrPm9zk18Fci19kk="
"x86_64-linux": "sha256-PhCLeam/Mx2KL0bu0kg1+TJ46CpEOmnkQQiK3myGqnk=",
"aarch64-linux": "sha256-UJ2pj9WQ3hYOJBw0rFwcDx4XrvZf3CIF+hK0A9FjNbc=",
"aarch64-darwin": "sha256-WiVvvLEpeWe/LMIgZUBJSkY2jfoxgDmHyddE9kp2Dyg=",
"x86_64-darwin": "sha256-67n45FB0BdM30hcipPZqgjPZkfKc1dK56z9kjSLIeFM="
}
}
+3 -3
View File
@@ -49,9 +49,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.9",
"@opentui/keymap": "0.5.9",
"@opentui/solid": "0.5.9",
"@opentui/core": "0.5.10",
"@opentui/keymap": "0.5.10",
"@opentui/solid": "0.5.10",
"@tanstack/solid-virtual": "3.13.37",
"@shikijs/stream": "4.4.3",
"@standard-schema/spec": "1.1.0",
@@ -1,7 +1,9 @@
import { expect, test } from "bun:test"
import { afterEach, expect, test, vi } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { batch, createRoot, createSignal } from "solid-js"
afterEach(() => vi.useRealTimers())
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>("steer")
@@ -66,3 +68,41 @@ test("does not animate visibility changes across identities", () => {
dispose()
})
})
test("holds brief appearances until the minimum duration expires", () => {
vi.useFakeTimers()
createRoot((dispose) => {
const [value, setValue] = createSignal<string>()
const presence = createAnimatedPresence(value, () => null, undefined, 1000)
setValue("shell")
vi.advanceTimersByTime(100)
setValue(undefined)
expect(presence.show()).toBe(true)
expect(presence.value()).toBe("shell")
vi.advanceTimersByTime(899)
expect(presence.show()).toBe(true)
vi.advanceTimersByTime(1)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(true)
dispose()
})
})
test("clears held appearances when identity changes", () => {
vi.useFakeTimers()
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("session-a")
const [value, setValue] = createSignal<string>("shell")
const presence = createAnimatedPresence(value, () => null, identity, 1000)
setValue(undefined)
expect(presence.show()).toBe(true)
setIdentity("session-b")
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(false)
expect(presence.value()).toBeUndefined()
dispose()
})
})
+1
View File
@@ -37,6 +37,7 @@ export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
path: Schema.optionalKey(RelativePath),
hidden: Schema.optionalKey(Schema.Boolean),
limit: Schema.optionalKey(PositiveInt),
}) {}
+21 -2
View File
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
})
for (const model of provider.models) {
if (model.status === "deprecated") continue
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, copy(model)))
}
}
})
@@ -65,8 +65,27 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return structuredClone(data).filter(
return copy(data).filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
// magnitude faster than structuredClone's general graph walk on the startup path.
function copy<T>(value: T): T {
if (Array.isArray(value)) return value.map(copy) as T
if (value !== null && typeof value === "object") {
const result: Record<string, unknown> = {}
for (const key of Object.keys(value)) {
const copied = copy((value as Record<string, unknown>)[key])
// Assigning this key would set the prototype rather than an own property, unlike structuredClone.
if (key === "__proto__")
Object.defineProperty(result, key, { value: copied, enumerable: true, writable: true, configurable: true })
else result[key] = copied
}
return result as T
}
return value
}
+18 -5
View File
@@ -80,13 +80,26 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
enabled.add(plugin.id)
}
const ordered = [
...pre.filter((plugin) => enabled.has(plugin.id)),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
]
// Registry activation dies on a duplicate ID, which would drop the whole generation including builtins.
// Keep the first occurrence in boot order and report later ones like any other plugin setup failure.
const duplicate = (plugin: Plugin.Generation, index: number) =>
ordered.findIndex((other) => other.id === plugin.id) !== index
return {
plugins: [
...pre.filter((plugin) => enabled.has(plugin.id)),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
plugins: ordered.filter((plugin, index) => !duplicate(plugin, index)),
failures: [
...failures.values(),
...ordered.filter(duplicate).map((plugin) => ({
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" as const },
state: { status: "failed" as const, error: `Duplicate plugin ID: ${plugin.id}` },
features: { server: true as const, ...plugin.features },
})),
],
failures: [...failures.values()],
pending: [...pending],
}
})
+5
View File
@@ -19,6 +19,9 @@ export const Input = Schema.Struct({
path: Schema.optionalKey(RelativePath).annotate({
description: "Directory to search. Defaults to the current working directory.",
}),
hidden: FileSystem.GlobInput.fields.hidden.annotate({
description: "Include hidden files and directories (default: false).",
}),
limit: FileSystem.GlobInput.fields.limit.annotate({
description: `Maximum number of matching files to return (default: ${FileSystem.DEFAULT_SEARCH_LIMIT})`,
}),
@@ -76,6 +79,7 @@ export const Plugin = {
metadata: {
root: searchPath ?? ".",
path: searchPath,
hidden: input.hidden,
limit: input.limit,
},
sessionID: context.sessionID,
@@ -97,6 +101,7 @@ export const Plugin = {
.glob({
cwd: root,
pattern: input.pattern,
hidden: input.hidden,
limit: limit + 1,
})
.pipe(
@@ -411,6 +411,60 @@ describe("ModelsDevPlugin", () => {
),
)
it.effect("copies model request bodies without reinterpreting literal __proto__ keys", () =>
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")
// A JSON body may legitimately contain a "__proto__" key; both copy stages must keep it as an own property.
const body = JSON.parse('{"__proto__":{"service_tier":"priority"},"keep":true}') as Record<string, unknown>
const snapshot = {
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
models: [
{
id: modelID,
modelID,
providerID,
name: "GPT-5.4",
capabilities: { tools: true, input: [], output: [] },
variants: [],
time: { released: Date.parse("2026-01-01") },
cost: [],
status: "active",
enabled: true,
limit: { context: 1_050_000, output: 128_000 },
body,
},
],
} satisfies ModelsDev.Snapshot
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 }),
),
)
const copied = (yield* catalog.model.get(providerID, modelID))?.body
expect(copied).not.toBe(body)
expect(Object.hasOwn(copied ?? {}, "__proto__")).toBe(true)
expect(Object.keys(copied ?? {})).toEqual(["__proto__", "keep"])
expect(JSON.stringify(copied)).toBe(JSON.stringify(body))
expect(copied).not.toHaveProperty("service_tier")
}),
)
it.effect("omits legacy provider aliases", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
@@ -0,0 +1,97 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
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 { Command } from "@opencode-ai/core/command"
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 { Database } from "../../src/database/database"
import { Bus } from "../../src/bus"
import { tempGlobalLayer } from "../fixture/global"
import { tmpdirScoped } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const id = Plugin.ID.make("account-prompts")
// Host and instance plugins share one ID; each registers a distinct command so the winner is observable.
const greeter = (command: string) =>
define({
id,
effect: (ctx) =>
ctx.command.transform((draft) => draft.add({ name: command, execute: () => Effect.void })).pipe(Effect.asVoid),
})
const instances = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const map = yield* LayerMap.make(
(ref: Location.Ref) =>
Instance.layer(ref, { discovery: false, plugins: [greeter("instance-greet")], replacements: bindings }),
{ idleTimeToLive: Duration.infinity },
)
const bindings: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
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(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
)
describe("PluginSupervisor", () => {
it.live("reports a duplicate plugin ID as a failure without dropping the generation", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
yield* sdk.register(greeter("host-greet"))
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
const state = yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const commands = yield* Command.Service
return {
inventory: yield* plugins.list(),
host: yield* commands.get("host-greet"),
instance: yield* commands.get("instance-greet"),
}
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
// Earlier boot order wins: the host SDK plugin activates and the instance copy is reported.
expect(state.inventory.filter((plugin) => plugin.id === id)).toEqual([
{ id, source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
{
id,
source: { type: "sdk" },
state: { status: "failed", error: `Duplicate plugin ID: ${id}` },
features: { server: true },
},
])
expect(state.host).toBeDefined()
expect(state.instance).toBeUndefined()
// Builtins stay active: the duplicate is a plugin failure, not a generation defect.
expect(
state.inventory.some((plugin) => plugin.id?.startsWith("opencode.") && plugin.state.status === "active"),
).toBe(true)
}),
)
})
+2 -2
View File
@@ -29,8 +29,8 @@
},
"peerDependencies": {
"@opencode-ai/theme": "workspace:*",
"@opentui/core": ">=0.5.9",
"@opentui/solid": ">=0.5.9",
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
+1 -1
View File
@@ -62,7 +62,7 @@ await opencode.sessions.prompt({ sessionID: session.id, text: "Review the change
- The same key and Location share one live instance. Different directories or workspace IDs always select separate instances, even with the same application key.
- `configure` runs on a cache miss, not on each prompt. Loaded instances live until the host closes; change the application key or restart the host to reconstruct their birth configuration. Plugin transforms and reloads remain available within that lifetime.
- Session metadata, message, inbox, and context reads do not initialize plugins; permission and form lists read instance services and therefore acquire the Session's instance. Configuration failure, an instance plugin ID that collides with a host `plugins` entry, or initial setup failure of a supplied plugin prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
- Session metadata, message, inbox, and context reads do not initialize plugins; permission and form lists read instance services and therefore acquire the Session's instance. Configuration failure or a supplied plugin reported as failed after activation, including initial setup failure or an ID that collides with a host `plugins` entry (the host plugin keeps the ID), prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
- Existing HTTP prompt middleware also acquires capabilities for an idempotent retry. After restart, that retry can reconstruct plugins before returning the original admission; prompt preparation and hooks do not rerun. Configuration failure can therefore block the retry even when its input was already saved.
- Instance selection is not an authorization or storage-isolation boundary. Plugin Session APIs and the existing plugin-ID-based durable storage retain their normal scope.
- Omitting `instances` preserves default Location sharing. Host-wide plugins remain separate from Session-selected configuration; retain host-wide catalog policy when locationless generation needs it.
+2 -14
View File
@@ -4,7 +4,6 @@ import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Plugin } from "@opencode-ai/core/plugin"
import type { InstancePlugins } from "@opencode-ai/core/plugin/instance"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Location } from "@opencode-ai/schema/location"
import type { Session } from "@opencode-ai/schema/session"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -27,7 +26,7 @@ export function node(options: Options, replacements: () => LayerNode.Replacement
return makeGlobalNode({
service: Instance.Service,
layer: layer(options, replacements),
deps: [LocationServiceMap.node, SdkPlugins.node],
deps: [LocationServiceMap.node],
})
}
@@ -37,7 +36,6 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
Effect.gen(function* () {
const scope = yield* Effect.scope
const locations = yield* LocationServiceMap.Service
const sdk = yield* SdkPlugins.Service
const key = (session: Session.Info) => ({
key: options.key(session),
...LocationServiceMap.canonical(session.location),
@@ -48,17 +46,6 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
Layer.unwrap(
Effect.gen(function* () {
const configuration = yield* options.configure(input.key).pipe(Effect.orDie)
// A host/instance ID collision fails the whole plugin generation, which leaves no inventory
// trace to check after activation. Reject it before constructing anything.
const collisions = configuration.plugins.filter((plugin) =>
sdk.all().some((host) => host.id === plugin.id),
)
if (collisions.length > 0)
yield* Effect.die(
new Error(
`Instance plugin IDs collide with host plugins: ${collisions.map((plugin) => plugin.id).join(", ")}`,
),
)
return Instance.layer(Location.Ref.make({ directory: input.directory, workspaceID: input.workspaceID }), {
plugins: configuration.plugins,
replacements: [
@@ -73,6 +60,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
// Covers setup failures and IDs colliding with host plugins; Core reports both in the inventory.
const failed = (yield* plugins.list()).filter(
(plugin) =>
plugin.state.status === "failed" &&
+11 -2
View File
@@ -298,6 +298,7 @@ test("Promise instance plugin ID collisions reject admission and reconstruct on
await using directory = await tmpdir("opencode-promise-instance-collision-")
const configured: string[] = []
const setups: string[] = []
const cleanups: string[] = []
await using opencode = await OpenCode.create({
...hostOptions(directory.path),
plugins: [
@@ -305,6 +306,9 @@ test("Promise instance plugin ID collisions reject admission and reconstruct on
id: "account-prompts",
setup() {
setups.push("host")
return () => {
cleanups.push("host")
}
},
},
],
@@ -342,12 +346,17 @@ test("Promise instance plugin ID collisions reject admission and reconstruct on
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([])
expect((await opencode.message.list({ sessionID: session.id })).data).toEqual([])
expect(configured).toEqual(["alpha"])
expect(setups).toEqual([])
// The host plugin wins the ID and activates inside the instance before the colliding instance plugin
// is reported failed; rejecting the instance tears that generation down again.
expect(setups).toEqual(["host"])
expect(cleanups).toEqual(["host"])
const admitted = await opencode.sessions.prompt(input)
expect(admitted.id).toBe(input.id)
expect(admitted.payload.text).toBe("alpha: Retry this input")
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([admitted])
expect(configured).toEqual(["alpha", "alpha"])
expect(setups).toEqual(["host", "alpha"])
// The reconstructed instance runs a fresh generation: host plugin again, then the renamed instance plugin.
expect(setups).toEqual(["host", "host", "alpha"])
expect(cleanups).toEqual(["host"])
}, 20_000)